diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index b377537..bf578e3 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -52,104 +52,78 @@ class MessagesTab extends StatefulWidget { class _ComposerActionTile extends StatelessWidget { final IconData icon; final String title; - final String subtitle; final Color color; final bool enabled; + final bool busy; final VoidCallback? onTap; const _ComposerActionTile({ required this.icon, required this.title, - required this.subtitle, required this.color, this.enabled = true, + this.busy = false, this.onTap, }); @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; final effectiveColor = enabled ? color : colorScheme.onSurfaceVariant.withValues(alpha: 0.45); return Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(24), + color: enabled + ? colorScheme.surfaceContainerLow + : 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, - child: Ink( + leading: Container( + width: 42, + height: 42, decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - enabled - ? colorScheme.surfaceContainerLow - : colorScheme.surfaceContainerHighest, - enabled - ? effectiveColor.withValues(alpha: 0.08) - : colorScheme.surfaceContainerHigh, - ], - ), - borderRadius: BorderRadius.circular(24), + color: effectiveColor.withValues(alpha: enabled ? 0.14 : 0.10), + borderRadius: BorderRadius.circular(14), border: Border.all( color: enabled ? effectiveColor.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( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 38, - height: 38, - decoration: BoxDecoration( - color: effectiveColor.withValues( - alpha: enabled ? 0.14 : 0.10, - ), - borderRadius: BorderRadius.circular(14), - ), - 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, - ), - ), - ], - ), + alignment: Alignment.center, + child: Icon(icon, color: effectiveColor, size: 20), + ), + title: Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + color: enabled + ? colorScheme.onSurface + : colorScheme.onSurfaceVariant, ), ), + 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 { void _showComposerActions() { showModalBottomSheet( context: context, - backgroundColor: Colors.transparent, + showDragHandle: true, + isScrollControlled: true, builder: (sheetContext) { final theme = Theme.of(sheetContext); - final colorScheme = theme.colorScheme; final l10n = AppLocalizations.of(context)!; Future runAction(Future Function() action) async { await _runAfterSheetDismissal(sheetContext, action); } + final actions = [ + _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( child: ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.of(sheetContext).size.height * 0.72, ), - child: Container( - margin: const EdgeInsets.fromLTRB(12, 0, 12, 12), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - colorScheme.surface, - colorScheme.surfaceContainerLow, - ], - ), - 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), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'More actions', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), - ], - ), - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 18), - 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 = [ - _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], - ); - }, - ), + const SizedBox(height: 16), + for (var index = 0; index < actions.length; index++) ...[ + actions[index], + if (index != actions.length - 1) const SizedBox(height: 8), ], - ), + ], ), ), ), diff --git a/lib/screens/sensors_tab.dart b/lib/screens/sensors_tab.dart index 18fb530..a6b92e7 100644 --- a/lib/screens/sensors_tab.dart +++ b/lib/screens/sensors_tab.dart @@ -105,120 +105,23 @@ class _SensorsTabState extends State { contactsProvider, connectionProvider: context.read(), ); - final searchController = TextEditingController(); - - try { - await showModalBottomSheet( - context: context, - showDragHandle: true, - isScrollControlled: true, - builder: (sheetContext) { - if (candidates.isEmpty) { - return const SafeArea( - child: Padding( - padding: EdgeInsets.all(24), - child: Text( - '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', - ), - ), - ); - }, - ), - ), - ], - ), - ), - ); - }, + await showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (sheetContext) => AddSensorSheet( + candidates: candidates, + onClose: () => Navigator.of(sheetContext).pop(), + onSelect: (contact) 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 _showMetricSelector( @@ -228,7 +131,7 @@ class _SensorsTabState extends State { ) async { await Navigator.of(context).push( MaterialPageRoute( - builder: (pageContext) => _SensorCustomizeView( + builder: (pageContext) => SensorCustomizeView( publicKeyHex: publicKeyHex, initialContact: contact, onRenameMetric: @@ -474,7 +377,150 @@ class _SensorsTabState extends State { } } -class _SensorCustomizeView extends StatelessWidget { +class AddSensorSheet extends StatefulWidget { + final List candidates; + final Future Function(Contact contact) onSelect; + final VoidCallback onClose; + + const AddSensorSheet({ + super.key, + required this.candidates, + required this.onSelect, + required this.onClose, + }); + + @override + State createState() => _AddSensorSheetState(); +} + +class _AddSensorSheetState extends State { + 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 Contact? initialContact; final Future Function({ @@ -485,7 +531,7 @@ class _SensorCustomizeView extends StatelessWidget { }) onRenameMetric; - const _SensorCustomizeView({ + const SensorCustomizeView({ required this.publicKeyHex, required this.initialContact, required this.onRenameMetric, diff --git a/test/screens/sensors_tab_test.dart b/test/screens/sensors_tab_test.dart index 979cfd4..26d2f95 100644 --- a/test/screens/sensors_tab_test.dart +++ b/test/screens/sensors_tab_test.dart @@ -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/sensors_provider.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:shared_preferences/shared_preferences.dart'; @@ -22,9 +23,12 @@ void main() { expect(provider.isLoaded, isTrue); } - Contact buildSensorContact() { + Contact buildSensorContact({ + int firstByte = 0x44, + String name = 'WX Station', + }) { final publicKey = Uint8List(32); - publicKey[0] = 0x44; + publicKey[0] = firstByte; return Contact( publicKey: publicKey, @@ -32,7 +36,7 @@ void main() { flags: 0, outPathLen: 0, outPath: Uint8List(64), - advName: 'WX Station', + advName: name, lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, advLat: 0, advLon: 0, @@ -70,19 +74,22 @@ void main() { child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, 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.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('Live preview'), findsOneWidget); expect(find.text('Refresh schedule'), findsOneWidget); @@ -94,4 +101,49 @@ void main() { ); 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.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); + }); }