mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
fix: Keep composer and sensor sheets closable #123
This commit is contained in:
@@ -52,104 +52,78 @@ class MessagesTab extends StatefulWidget {
|
|||||||
class _ComposerActionTile extends StatelessWidget {
|
class _ComposerActionTile extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String title;
|
final String title;
|
||||||
final String subtitle;
|
|
||||||
final Color color;
|
final Color color;
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
|
final bool busy;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
const _ComposerActionTile({
|
const _ComposerActionTile({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.subtitle,
|
|
||||||
required this.color,
|
required this.color,
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
|
this.busy = false,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
final effectiveColor = enabled
|
final effectiveColor = enabled
|
||||||
? color
|
? color
|
||||||
: colorScheme.onSurfaceVariant.withValues(alpha: 0.45);
|
: colorScheme.onSurfaceVariant.withValues(alpha: 0.45);
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
color: Colors.transparent,
|
color: enabled
|
||||||
child: InkWell(
|
? colorScheme.surfaceContainerLow
|
||||||
borderRadius: BorderRadius.circular(24),
|
: colorScheme.surfaceContainerLowest,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
enabled: enabled,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
onTap: enabled ? onTap : null,
|
onTap: enabled ? onTap : null,
|
||||||
child: Ink(
|
leading: Container(
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
color: effectiveColor.withValues(alpha: enabled ? 0.14 : 0.10),
|
||||||
begin: Alignment.topLeft,
|
borderRadius: BorderRadius.circular(14),
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [
|
|
||||||
enabled
|
|
||||||
? colorScheme.surfaceContainerLow
|
|
||||||
: colorScheme.surfaceContainerHighest,
|
|
||||||
enabled
|
|
||||||
? effectiveColor.withValues(alpha: 0.08)
|
|
||||||
: colorScheme.surfaceContainerHigh,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: enabled
|
color: enabled
|
||||||
? effectiveColor.withValues(alpha: 0.16)
|
? effectiveColor.withValues(alpha: 0.16)
|
||||||
: colorScheme.outlineVariant.withValues(alpha: 0.16),
|
: colorScheme.outlineVariant.withValues(alpha: 0.16),
|
||||||
),
|
),
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: effectiveColor.withValues(alpha: enabled ? 0.08 : 0.03),
|
|
||||||
blurRadius: 14,
|
|
||||||
offset: const Offset(0, 8),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: Padding(
|
alignment: Alignment.center,
|
||||||
padding: const EdgeInsets.all(12),
|
child: Icon(icon, color: effectiveColor, size: 20),
|
||||||
child: Column(
|
),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
title: Text(
|
||||||
children: [
|
title,
|
||||||
Container(
|
maxLines: 2,
|
||||||
width: 38,
|
overflow: TextOverflow.ellipsis,
|
||||||
height: 38,
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
decoration: BoxDecoration(
|
fontWeight: FontWeight.w700,
|
||||||
color: effectiveColor.withValues(
|
letterSpacing: -0.2,
|
||||||
alpha: enabled ? 0.14 : 0.10,
|
color: enabled
|
||||||
),
|
? colorScheme.onSurface
|
||||||
borderRadius: BorderRadius.circular(14),
|
: colorScheme.onSurfaceVariant,
|
||||||
),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Icon(icon, color: effectiveColor, size: 20),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
letterSpacing: -0.2,
|
|
||||||
color: enabled
|
|
||||||
? colorScheme.onSurface
|
|
||||||
: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
subtitle,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
height: 1.15,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
trailing: busy
|
||||||
|
? SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.2,
|
||||||
|
color: effectiveColor,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
Icons.chevron_right_rounded,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1599,206 +1573,107 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
void _showComposerActions() {
|
void _showComposerActions() {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.transparent,
|
showDragHandle: true,
|
||||||
|
isScrollControlled: true,
|
||||||
builder: (sheetContext) {
|
builder: (sheetContext) {
|
||||||
final theme = Theme.of(sheetContext);
|
final theme = Theme.of(sheetContext);
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
Future<void> runAction(Future<void> Function() action) async {
|
Future<void> runAction(Future<void> Function() action) async {
|
||||||
await _runAfterSheetDismissal(sheetContext, action);
|
await _runAfterSheetDismissal(sheetContext, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final actions = <Widget>[
|
||||||
|
_ComposerActionTile(
|
||||||
|
icon: Icons.search_rounded,
|
||||||
|
title: l10n.searchMessages,
|
||||||
|
color: const Color(0xFF2B6CB0),
|
||||||
|
onTap: () => runAction(() async {
|
||||||
|
_showFilteredMessageSearch();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
_ComposerActionTile(
|
||||||
|
icon: Icons.add_location_alt_rounded,
|
||||||
|
title: l10n.sendSarMarker,
|
||||||
|
color: const Color(0xFFB45309),
|
||||||
|
onTap: () => runAction(() async {
|
||||||
|
_showSarDialog();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
if (_voiceSupported)
|
||||||
|
_ComposerActionTile(
|
||||||
|
icon: _isRecording ? Icons.stop_rounded : Icons.mic_rounded,
|
||||||
|
title: _isRecording ? 'Stop recording' : 'Record voice',
|
||||||
|
color: const Color(0xFF7C3AED),
|
||||||
|
enabled: !_isSendingVoice,
|
||||||
|
busy: _isSendingVoice,
|
||||||
|
onTap: !_isSendingVoice
|
||||||
|
? () => runAction(() async {
|
||||||
|
if (_isRecording) {
|
||||||
|
await _stopAndSendVoice();
|
||||||
|
} else {
|
||||||
|
await _startVoiceRecording();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
_ComposerActionTile(
|
||||||
|
icon: Icons.photo_library_rounded,
|
||||||
|
title: l10n.sendImageFromGallery,
|
||||||
|
color: const Color(0xFF0F766E),
|
||||||
|
enabled: !_isSendingImage,
|
||||||
|
busy: _isSendingImage,
|
||||||
|
onTap: !_isSendingImage
|
||||||
|
? () => runAction(() async {
|
||||||
|
await _pickAndSendImage(source: ImageSource.gallery);
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
_ComposerActionTile(
|
||||||
|
icon: Icons.camera_alt_rounded,
|
||||||
|
title: l10n.takePhoto,
|
||||||
|
color: const Color(0xFF2563EB),
|
||||||
|
enabled: !_isSendingImage,
|
||||||
|
busy: _isSendingImage,
|
||||||
|
onTap: !_isSendingImage
|
||||||
|
? () => runAction(() async {
|
||||||
|
await _pickAndSendImage(source: ImageSource.camera);
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
_ComposerActionTile(
|
||||||
|
icon: Icons.grid_3x3_rounded,
|
||||||
|
title: l10n.startTictactoe,
|
||||||
|
color: const Color(0xFFBE185D),
|
||||||
|
onTap: () => runAction(() async {
|
||||||
|
await _startTicTacToeGame();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxHeight: MediaQuery.of(sheetContext).size.height * 0.72,
|
maxHeight: MediaQuery.of(sheetContext).size.height * 0.72,
|
||||||
),
|
),
|
||||||
child: Container(
|
child: SingleChildScrollView(
|
||||||
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||||
decoration: BoxDecoration(
|
child: Column(
|
||||||
gradient: LinearGradient(
|
mainAxisSize: MainAxisSize.min,
|
||||||
begin: Alignment.topCenter,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
end: Alignment.bottomCenter,
|
children: [
|
||||||
colors: [
|
Text(
|
||||||
colorScheme.surface,
|
'More actions',
|
||||||
colorScheme.surfaceContainerLow,
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
],
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
borderRadius: const BorderRadius.vertical(
|
|
||||||
top: Radius.circular(32),
|
|
||||||
bottom: Radius.circular(28),
|
|
||||||
),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: colorScheme.shadow.withValues(alpha: 0.14),
|
|
||||||
blurRadius: 28,
|
|
||||||
offset: const Offset(0, 10),
|
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(height: 16),
|
||||||
),
|
for (var index = 0; index < actions.length; index++) ...[
|
||||||
child: SingleChildScrollView(
|
actions[index],
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 18),
|
if (index != actions.length - 1) const SizedBox(height: 8),
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Center(
|
|
||||||
child: Container(
|
|
||||||
width: 44,
|
|
||||||
height: 4,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.outlineVariant,
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'More actions',
|
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
letterSpacing: -0.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'Search, share, or start something from this chat.',
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHigh,
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
border: Border.all(
|
|
||||||
color: colorScheme.outlineVariant.withValues(
|
|
||||||
alpha: 0.18,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: IconButton(
|
|
||||||
onPressed: () => Navigator.pop(sheetContext),
|
|
||||||
tooltip: l10n.close,
|
|
||||||
icon: const Icon(Icons.close_rounded),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final actions = <Widget>[
|
|
||||||
_ComposerActionTile(
|
|
||||||
icon: Icons.search_rounded,
|
|
||||||
title: l10n.searchMessages,
|
|
||||||
subtitle: 'Find text in the current conversation',
|
|
||||||
color: const Color(0xFF2B6CB0),
|
|
||||||
onTap: () => runAction(() async {
|
|
||||||
_showFilteredMessageSearch();
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
_ComposerActionTile(
|
|
||||||
icon: Icons.add_location_alt_rounded,
|
|
||||||
title: l10n.sendSarMarker,
|
|
||||||
subtitle: 'Share a marker with coordinates',
|
|
||||||
color: const Color(0xFFB45309),
|
|
||||||
onTap: () => runAction(() async {
|
|
||||||
_showSarDialog();
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
if (_voiceSupported)
|
|
||||||
_ComposerActionTile(
|
|
||||||
icon: _isRecording
|
|
||||||
? Icons.stop_rounded
|
|
||||||
: Icons.mic_rounded,
|
|
||||||
title: _isRecording
|
|
||||||
? 'Stop recording'
|
|
||||||
: 'Record voice',
|
|
||||||
subtitle: _isSendingVoice
|
|
||||||
? 'Voice message is sending'
|
|
||||||
: _isRecording
|
|
||||||
? 'Finish and send your clip'
|
|
||||||
: 'Capture and send a voice note',
|
|
||||||
color: const Color(0xFF7C3AED),
|
|
||||||
enabled: !_isSendingVoice,
|
|
||||||
onTap: !_isSendingVoice
|
|
||||||
? () => runAction(() async {
|
|
||||||
if (_isRecording) {
|
|
||||||
await _stopAndSendVoice();
|
|
||||||
} else {
|
|
||||||
await _startVoiceRecording();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
_ComposerActionTile(
|
|
||||||
icon: Icons.photo_library_rounded,
|
|
||||||
title: l10n.sendImageFromGallery,
|
|
||||||
subtitle: 'Choose an image from your library',
|
|
||||||
color: const Color(0xFF0F766E),
|
|
||||||
enabled: !_isSendingImage,
|
|
||||||
onTap: !_isSendingImage
|
|
||||||
? () => runAction(() async {
|
|
||||||
await _pickAndSendImage(
|
|
||||||
source: ImageSource.gallery,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
_ComposerActionTile(
|
|
||||||
icon: Icons.camera_alt_rounded,
|
|
||||||
title: l10n.takePhoto,
|
|
||||||
subtitle: 'Capture something right now',
|
|
||||||
color: const Color(0xFF2563EB),
|
|
||||||
enabled: !_isSendingImage,
|
|
||||||
onTap: !_isSendingImage
|
|
||||||
? () => runAction(() async {
|
|
||||||
await _pickAndSendImage(
|
|
||||||
source: ImageSource.camera,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
_ComposerActionTile(
|
|
||||||
icon: Icons.grid_3x3_rounded,
|
|
||||||
title: l10n.startTictactoe,
|
|
||||||
subtitle: l10n.dmOnly,
|
|
||||||
color: const Color(0xFFBE185D),
|
|
||||||
onTap: () => runAction(() async {
|
|
||||||
await _startTicTacToeGame();
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
return GridView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
itemCount: actions.length,
|
|
||||||
gridDelegate:
|
|
||||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: 2,
|
|
||||||
mainAxisSpacing: 12,
|
|
||||||
crossAxisSpacing: 12,
|
|
||||||
mainAxisExtent: 126,
|
|
||||||
),
|
|
||||||
itemBuilder: (context, index) => actions[index],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -105,120 +105,23 @@ class _SensorsTabState extends State<SensorsTab> {
|
|||||||
contactsProvider,
|
contactsProvider,
|
||||||
connectionProvider: context.read<ConnectionProvider>(),
|
connectionProvider: context.read<ConnectionProvider>(),
|
||||||
);
|
);
|
||||||
final searchController = TextEditingController();
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
try {
|
showDragHandle: true,
|
||||||
await showModalBottomSheet<void>(
|
isScrollControlled: true,
|
||||||
context: context,
|
builder: (sheetContext) => AddSensorSheet(
|
||||||
showDragHandle: true,
|
candidates: candidates,
|
||||||
isScrollControlled: true,
|
onClose: () => Navigator.of(sheetContext).pop(),
|
||||||
builder: (sheetContext) {
|
onSelect: (contact) async {
|
||||||
if (candidates.isEmpty) {
|
await sensorsProvider.addSensor(contact);
|
||||||
return const SafeArea(
|
if (!sheetContext.mounted) return;
|
||||||
child: Padding(
|
Navigator.of(sheetContext).pop();
|
||||||
padding: EdgeInsets.all(24),
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
child: Text(
|
SnackBar(content: Text('${contact.displayName} added to Sensors')),
|
||||||
'No eligible nodes available. Discover a relay or node first.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return StatefulBuilder(
|
|
||||||
builder: (context, setModalState) {
|
|
||||||
final query = searchController.text.trim().toLowerCase();
|
|
||||||
final filteredCandidates = candidates.where((contact) {
|
|
||||||
if (query.isEmpty) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return contact.displayName.toLowerCase().contains(query) ||
|
|
||||||
contact.publicKeyHex.toLowerCase().contains(query);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
return SafeArea(
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
|
||||||
),
|
|
||||||
child: ListView(
|
|
||||||
shrinkWrap: true,
|
|
||||||
padding: const EdgeInsets.only(bottom: 20),
|
|
||||||
children: [
|
|
||||||
ListTile(
|
|
||||||
title: const Text(
|
|
||||||
'Add sensor node',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
AppLocalizations.of(
|
|
||||||
context,
|
|
||||||
)!.pickARelayOrNodeToWatchInSensors,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
|
|
||||||
child: TextField(
|
|
||||||
controller: searchController,
|
|
||||||
onChanged: (_) => setModalState(() {}),
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
prefixIcon: Icon(Icons.search),
|
|
||||||
hintText: 'Search sensors',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (filteredCandidates.isEmpty)
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: 20,
|
|
||||||
vertical: 16,
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'No sensor candidates match your search.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
...filteredCandidates.map(
|
|
||||||
(contact) => ListTile(
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 20,
|
|
||||||
vertical: 4,
|
|
||||||
),
|
|
||||||
leading: CircleAvatar(
|
|
||||||
radius: 24,
|
|
||||||
backgroundColor: const Color(0xFFDDEAF8),
|
|
||||||
child: Icon(
|
|
||||||
_typeIcon(contact),
|
|
||||||
color: const Color(0xFF1E4F7A),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(contact.displayName),
|
|
||||||
subtitle: _SensorCandidatePreview(contact: contact),
|
|
||||||
isThreeLine: true,
|
|
||||||
onTap: () async {
|
|
||||||
await sensorsProvider.addSensor(contact);
|
|
||||||
if (!sheetContext.mounted) return;
|
|
||||||
Navigator.of(sheetContext).pop();
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'${contact.displayName} added to Sensors',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
),
|
||||||
} finally {
|
);
|
||||||
searchController.dispose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showMetricSelector(
|
Future<void> _showMetricSelector(
|
||||||
@@ -228,7 +131,7 @@ class _SensorsTabState extends State<SensorsTab> {
|
|||||||
) async {
|
) async {
|
||||||
await Navigator.of(context).push(
|
await Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
builder: (pageContext) => _SensorCustomizeView(
|
builder: (pageContext) => SensorCustomizeView(
|
||||||
publicKeyHex: publicKeyHex,
|
publicKeyHex: publicKeyHex,
|
||||||
initialContact: contact,
|
initialContact: contact,
|
||||||
onRenameMetric:
|
onRenameMetric:
|
||||||
@@ -474,7 +377,150 @@ class _SensorsTabState extends State<SensorsTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SensorCustomizeView extends StatelessWidget {
|
class AddSensorSheet extends StatefulWidget {
|
||||||
|
final List<Contact> candidates;
|
||||||
|
final Future<void> Function(Contact contact) onSelect;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
|
||||||
|
const AddSensorSheet({
|
||||||
|
super.key,
|
||||||
|
required this.candidates,
|
||||||
|
required this.onSelect,
|
||||||
|
required this.onClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AddSensorSheet> createState() => _AddSensorSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddSensorSheetState extends State<AddSensorSheet> {
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (widget.candidates.isEmpty) {
|
||||||
|
return const SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
'No eligible nodes available. Discover a relay or node first.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final query = _searchController.text.trim().toLowerCase();
|
||||||
|
final filteredCandidates = widget.candidates.where((contact) {
|
||||||
|
if (query.isEmpty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return contact.displayName.toLowerCase().contains(query) ||
|
||||||
|
contact.publicKeyHex.toLowerCase().contains(query);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.8,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 12, 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Add sensor node',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(l10n.pickARelayOrNodeToWatchInSensors),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: widget.onClose,
|
||||||
|
tooltip: l10n.close,
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
hintText: 'Search sensors',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: filteredCandidates.isEmpty
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 16,
|
||||||
|
),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: Text(
|
||||||
|
'No sensor candidates match your search.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.only(bottom: 20),
|
||||||
|
itemCount: filteredCandidates.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final contact = filteredCandidates[index];
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
leading: CircleAvatar(
|
||||||
|
radius: 24,
|
||||||
|
backgroundColor: const Color(0xFFDDEAF8),
|
||||||
|
child: Icon(
|
||||||
|
_typeIcon(contact),
|
||||||
|
color: const Color(0xFF1E4F7A),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(contact.displayName),
|
||||||
|
subtitle: _SensorCandidatePreview(contact: contact),
|
||||||
|
isThreeLine: true,
|
||||||
|
onTap: () => widget.onSelect(contact),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SensorCustomizeView extends StatelessWidget {
|
||||||
final String publicKeyHex;
|
final String publicKeyHex;
|
||||||
final Contact? initialContact;
|
final Contact? initialContact;
|
||||||
final Future<void> Function({
|
final Future<void> Function({
|
||||||
@@ -485,7 +531,7 @@ class _SensorCustomizeView extends StatelessWidget {
|
|||||||
})
|
})
|
||||||
onRenameMetric;
|
onRenameMetric;
|
||||||
|
|
||||||
const _SensorCustomizeView({
|
const SensorCustomizeView({
|
||||||
required this.publicKeyHex,
|
required this.publicKeyHex,
|
||||||
required this.initialContact,
|
required this.initialContact,
|
||||||
required this.onRenameMetric,
|
required this.onRenameMetric,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:meshcore_sar_app/models/contact.dart';
|
|||||||
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
||||||
import 'package:meshcore_sar_app/providers/sensors_provider.dart';
|
import 'package:meshcore_sar_app/providers/sensors_provider.dart';
|
||||||
import 'package:meshcore_sar_app/screens/sensors_tab.dart';
|
import 'package:meshcore_sar_app/screens/sensors_tab.dart';
|
||||||
|
import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
@@ -22,9 +23,12 @@ void main() {
|
|||||||
expect(provider.isLoaded, isTrue);
|
expect(provider.isLoaded, isTrue);
|
||||||
}
|
}
|
||||||
|
|
||||||
Contact buildSensorContact() {
|
Contact buildSensorContact({
|
||||||
|
int firstByte = 0x44,
|
||||||
|
String name = 'WX Station',
|
||||||
|
}) {
|
||||||
final publicKey = Uint8List(32);
|
final publicKey = Uint8List(32);
|
||||||
publicKey[0] = 0x44;
|
publicKey[0] = firstByte;
|
||||||
|
|
||||||
return Contact(
|
return Contact(
|
||||||
publicKey: publicKey,
|
publicKey: publicKey,
|
||||||
@@ -32,7 +36,7 @@ void main() {
|
|||||||
flags: 0,
|
flags: 0,
|
||||||
outPathLen: 0,
|
outPathLen: 0,
|
||||||
outPath: Uint8List(64),
|
outPath: Uint8List(64),
|
||||||
advName: 'WX Station',
|
advName: name,
|
||||||
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
advLat: 0,
|
advLat: 0,
|
||||||
advLon: 0,
|
advLon: 0,
|
||||||
@@ -70,19 +74,22 @@ void main() {
|
|||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
home: const SensorsTab(),
|
home: SensorCustomizeView(
|
||||||
|
publicKeyHex: contact.publicKeyHex,
|
||||||
|
initialContact: contact,
|
||||||
|
onRenameMetric:
|
||||||
|
({
|
||||||
|
required BuildContext context,
|
||||||
|
required String publicKeyHex,
|
||||||
|
required SensorMetricOption option,
|
||||||
|
required SensorsProvider sensorsProvider,
|
||||||
|
}) async {},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
await tester.tap(find.byIcon(Icons.more_vert));
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump(const Duration(milliseconds: 300));
|
|
||||||
await tester.tap(find.text('Customize fields'));
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump(const Duration(milliseconds: 300));
|
|
||||||
|
|
||||||
expect(find.text('Customize WX Station'), findsOneWidget);
|
expect(find.text('Customize WX Station'), findsOneWidget);
|
||||||
expect(find.text('Live preview'), findsOneWidget);
|
expect(find.text('Live preview'), findsOneWidget);
|
||||||
expect(find.text('Refresh schedule'), findsOneWidget);
|
expect(find.text('Refresh schedule'), findsOneWidget);
|
||||||
@@ -94,4 +101,49 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(find.text('Channel 2'), findsOneWidget);
|
expect(find.text('Channel 2'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('add sensor sheet keeps close action available when scrolled', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(320, 480);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
var didClose = false;
|
||||||
|
final candidates = List<Contact>.generate(
|
||||||
|
20,
|
||||||
|
(index) =>
|
||||||
|
buildSensorContact(firstByte: index + 1, name: 'Sensor ${index + 1}'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: AddSensorSheet(
|
||||||
|
candidates: candidates,
|
||||||
|
onSelect: (_) async {},
|
||||||
|
onClose: () {
|
||||||
|
didClose = true;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final closeButton = find.byTooltip('Close');
|
||||||
|
expect(closeButton, findsOneWidget);
|
||||||
|
|
||||||
|
await tester.drag(find.byType(ListView).last, const Offset(0, -600));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(closeButton, findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(closeButton);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(didClose, isTrue);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user