From 6ff950d426cc466c01a1705689d3833cec42287d Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 29 Dec 2025 20:01:16 -0700 Subject: [PATCH] fixes --- lib/connector/meshcore_connector.dart | 42 ++- lib/connector/meshcore_protocol.dart | 38 +++ lib/screens/app_settings_screen.dart | 37 +-- lib/screens/ble_debug_log_screen.dart | 103 +++---- lib/screens/channel_chat_screen.dart | 111 ++++---- lib/screens/channel_message_path_screen.dart | 150 +++++----- lib/screens/channels_screen.dart | 168 ++++++------ lib/screens/contacts_screen.dart | 272 ++++++++++--------- lib/screens/map_screen.dart | 213 ++++++++------- lib/screens/repeater_hub_screen.dart | 209 +++++++------- lib/screens/repeater_settings_screen.dart | 39 +-- lib/screens/repeater_status_screen.dart | 25 +- lib/screens/scanner_screen.dart | 31 ++- lib/screens/settings_screen.dart | 84 +++--- 14 files changed, 827 insertions(+), 695 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index c22477a..ba88ee6 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -613,10 +613,20 @@ class MeshCoreConnector extends ChangeNotifier { throw Exception("MeshCore characteristics not found"); } - // Give the device a moment to be ready for descriptor writes - await Future.delayed(const Duration(milliseconds: 300)); - - await _txCharacteristic!.setNotifyValue(true); + // Retry setNotifyValue with increasing delays + bool notifySet = false; + for (int attempt = 0; attempt < 3 && !notifySet; attempt++) { + try { + if (attempt > 0) { + await Future.delayed(Duration(milliseconds: 500 * attempt)); + } + await _txCharacteristic!.setNotifyValue(true); + notifySet = true; + } catch (e) { + debugPrint('setNotifyValue attempt ${attempt + 1}/3 failed: $e'); + if (attempt == 2) rethrow; + } + } _notifySubscription = _txCharacteristic!.onValueReceived.listen(_handleFrame); _setState(MeshCoreConnectionState.connected); @@ -1185,6 +1195,30 @@ class MeshCoreConnector extends ChangeNotifier { await sendFrame(bytes); } + Future setNodeName(String name) async { + if (!isConnected) return; + await sendFrame(buildSetAdvertNameFrame(name)); + } + + Future setNodeLocation({required double lat, required double lon}) async { + if (!isConnected) return; + await sendFrame(buildSetAdvertLatLonFrame(lat, lon)); + } + + Future sendSelfAdvert({bool flood = true}) async { + if (!isConnected) return; + await sendFrame(buildSendSelfAdvertFrame(flood: flood)); + } + + Future rebootDevice() async { + if (!isConnected) return; + await sendFrame(buildRebootFrame()); + } + + Future setPrivacyMode(bool enabled) async { + await sendCliCommand('set privacy ${enabled ? 'on' : 'off'}'); + } + Future getChannels({int? maxChannels}) async { if (!isConnected) return; diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 6f09ac1..2018ada 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -210,6 +210,11 @@ void writeUint32LE(Uint8List data, int offset, int value) { data[offset + 3] = (value >> 24) & 0xFF; } +// Helper to write int32 little-endian +void writeInt32LE(Uint8List data, int offset, int value) { + writeUint32LE(data, offset, value & 0xFFFFFFFF); +} + // Helper to read null-terminated UTF-8 string String readCString(Uint8List data, int offset, int maxLen) { int end = offset; @@ -362,6 +367,39 @@ Uint8List buildSetDeviceTimeFrame(int timestamp) { return frame; } +// Build CMD_SEND_SELF_ADVERT frame +// Format: [cmd][flood_flag] +Uint8List buildSendSelfAdvertFrame({bool flood = false}) { + return Uint8List.fromList([cmdSendSelfAdvert, flood ? 1 : 0]); +} + +// Build CMD_SET_ADVERT_NAME frame +// Format: [cmd][name...] +Uint8List buildSetAdvertNameFrame(String name) { + final nameBytes = utf8.encode(name); + final nameLen = nameBytes.length < maxNameSize ? nameBytes.length : maxNameSize - 1; + final frame = Uint8List(1 + nameLen); + frame[0] = cmdSetAdvertName; + frame.setRange(1, 1 + nameLen, nameBytes.sublist(0, nameLen)); + return frame; +} + +// Build CMD_SET_ADVERT_LATLON frame +// Format: [cmd][lat x4][lon x4] +Uint8List buildSetAdvertLatLonFrame(double lat, double lon) { + final frame = Uint8List(9); + frame[0] = cmdSetAdvertLatLon; + writeInt32LE(frame, 1, (lat * 1000000).round()); + writeInt32LE(frame, 5, (lon * 1000000).round()); + return frame; +} + +// Build CMD_REBOOT frame +// Format: [cmd]["reboot"] +Uint8List buildRebootFrame() { + return Uint8List.fromList([cmdReboot, ...utf8.encode('reboot')]); +} + // Build CMD_SYNC_NEXT_MESSAGE frame Uint8List buildSyncNextMessageFrame() { return Uint8List.fromList([cmdSyncNextMessage]); diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index 746f4b8..7c266a3 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -16,23 +16,26 @@ class AppSettingsScreen extends StatelessWidget { title: const Text('App Settings'), centerTitle: true, ), - body: Consumer2( - builder: (context, settingsService, connector, child) { - return ListView( - padding: const EdgeInsets.all(16), - children: [ - _buildAppearanceCard(context, settingsService), - const SizedBox(height: 16), - _buildNotificationsCard(context, settingsService), - const SizedBox(height: 16), - _buildMessagingCard(context, settingsService), - const SizedBox(height: 16), - _buildBatteryCard(context, settingsService, connector), - const SizedBox(height: 16), - _buildMapSettingsCard(context, settingsService), - ], - ); - }, + body: SafeArea( + top: false, + child: Consumer2( + builder: (context, settingsService, connector, child) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildAppearanceCard(context, settingsService), + const SizedBox(height: 16), + _buildNotificationsCard(context, settingsService), + const SizedBox(height: 16), + _buildMessagingCard(context, settingsService), + const SizedBox(height: 16), + _buildBatteryCard(context, settingsService, connector), + const SizedBox(height: 16), + _buildMapSettingsCard(context, settingsService), + ], + ); + }, + ), ), ); } diff --git a/lib/screens/ble_debug_log_screen.dart b/lib/screens/ble_debug_log_screen.dart index f7689d2..6072201 100644 --- a/lib/screens/ble_debug_log_screen.dart +++ b/lib/screens/ble_debug_log_screen.dart @@ -59,63 +59,66 @@ class _BleDebugLogScreenState extends State { ), ], ), - body: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: SegmentedButton<_BleLogView>( - segments: const [ - ButtonSegment(value: _BleLogView.frames, label: Text('Frames')), - ButtonSegment(value: _BleLogView.rawLogRx, label: Text('Raw Log-RX')), - ], - selected: {_view}, - onSelectionChanged: (selection) { - setState(() => _view = selection.first); - }, + body: SafeArea( + top: false, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: SegmentedButton<_BleLogView>( + segments: const [ + ButtonSegment(value: _BleLogView.frames, label: Text('Frames')), + ButtonSegment(value: _BleLogView.rawLogRx, label: Text('Raw Log-RX')), + ], + selected: {_view}, + onSelectionChanged: (selection) { + setState(() => _view = selection.first); + }, + ), ), - ), - const SizedBox(height: 8), - Expanded( - child: hasEntries - ? ListView.separated( - itemCount: showingFrames ? entries.length : rawEntries.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (context, index) { - if (showingFrames) { - final entry = entries[index]; + const SizedBox(height: 8), + Expanded( + child: hasEntries + ? ListView.separated( + itemCount: showingFrames ? entries.length : rawEntries.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + if (showingFrames) { + final entry = entries[index]; + final time = + '${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}'; + return ListTile( + dense: true, + title: Text(entry.description), + subtitle: Text('${entry.hexPreview}\n$time'), + isThreeLine: true, + leading: Icon( + entry.outgoing ? Icons.upload : Icons.download, + size: 18, + ), + ); + } + + final entry = rawEntries[index]; + final info = _decodeRawPacket(entry.payload); final time = '${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}'; return ListTile( dense: true, - title: Text(entry.description), - subtitle: Text('${entry.hexPreview}\n$time'), + title: Text(info.title), + subtitle: Text('${info.summary}\n$time'), isThreeLine: true, - leading: Icon( - entry.outgoing ? Icons.upload : Icons.download, - size: 18, - ), + leading: const Icon(Icons.download, size: 18), + onTap: () => _showRawDialog(context, info), ); - } - - final entry = rawEntries[index]; - final info = _decodeRawPacket(entry.payload); - final time = - '${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}'; - return ListTile( - dense: true, - title: Text(info.title), - subtitle: Text('${info.summary}\n$time'), - isThreeLine: true, - leading: const Icon(Icons.download, size: 18), - onTap: () => _showRawDialog(context, info), - ); - }, - ) - : const Center( - child: Text('No BLE activity yet'), - ), - ), - ], + }, + ) + : const Center( + child: Text('No BLE activity yet'), + ), + ), + ], + ), ), ); }, diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index b04cab8..6fb8358 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -100,66 +100,69 @@ class _ChannelChatScreenState extends State { ), centerTitle: false, ), - body: Column( - children: [ - Expanded( - child: Consumer( - builder: (context, connector, child) { - final messages = connector.getChannelMessages(widget.channel); + body: SafeArea( + top: false, + child: Column( + children: [ + Expanded( + child: Consumer( + builder: (context, connector, child) { + final messages = connector.getChannelMessages(widget.channel); - WidgetsBinding.instance.addPostFrameCallback((_) { - _scrollToBottom(); - }); + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToBottom(); + }); - if (messages.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - widget.channel.isPublicChannel - ? Icons.public - : Icons.tag, - size: 64, - color: Colors.grey[400], - ), - const SizedBox(height: 16), - Text( - 'No messages yet', - style: TextStyle( - fontSize: 16, - color: Colors.grey[600], + if (messages.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + widget.channel.isPublicChannel + ? Icons.public + : Icons.tag, + size: 64, + color: Colors.grey[400], ), - ), - const SizedBox(height: 8), - Text( - 'Send a message to get started', - style: TextStyle( - fontSize: 14, - color: Colors.grey[500], + const SizedBox(height: 16), + Text( + 'No messages yet', + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), ), - ), - ], - ), + const SizedBox(height: 8), + Text( + 'Send a message to get started', + style: TextStyle( + fontSize: 14, + color: Colors.grey[500], + ), + ), + ], + ), + ); + } + + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(8), + cacheExtent: 0, + addAutomaticKeepAlives: false, + itemCount: messages.length, + itemBuilder: (context, index) { + final message = messages[index]; + return _buildMessageBubble(message); + }, ); - } - - return ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(8), - cacheExtent: 0, - addAutomaticKeepAlives: false, - itemCount: messages.length, - itemBuilder: (context, index) { - final message = messages[index]; - return _buildMessageBubble(message); - }, - ); - }, + }, + ), ), - ), - _buildMessageComposer(), - ], + _buildMessageComposer(), + ], + ), ), ); } diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 16ef349..46c8283 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -48,33 +48,36 @@ class ChannelMessagePathScreen extends StatelessWidget { ), ], ), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - _buildSummaryCard(context, observedLabel: observedLabel), - const SizedBox(height: 16), - if (extraPaths.isNotEmpty) ...[ + body: SafeArea( + top: false, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildSummaryCard(context, observedLabel: observedLabel), + const SizedBox(height: 16), + if (extraPaths.isNotEmpty) ...[ + Text( + 'Other Observed Paths', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + _buildPathVariants(context, extraPaths), + const SizedBox(height: 16), + ], Text( - 'Other Observed Paths', + 'Repeater Hops', style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 8), - _buildPathVariants(context, extraPaths), - const SizedBox(height: 16), + if (!hasHopDetails) + const Text( + 'Hop details are not provided for this packet.', + style: TextStyle(color: Colors.grey), + ) + else + ..._buildHopTiles(hops), ], - Text( - 'Repeater Hops', - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 8), - if (!hasHopDetails) - const Text( - 'Hop details are not provided for this packet.', - style: TextStyle(color: Colors.grey), - ) - else - ..._buildHopTiles(hops), - ], + ), ), ); }, @@ -296,60 +299,63 @@ class _ChannelMessagePathMapScreenState extends State 1) - _buildPathSelector( - context, - observedPaths, - selectedIndex, - (index) { - setState(() { - _selectedPath = observedPaths[index].pathBytes; - }); - }, + children: [ + TileLayer( + urlTemplate: kMapTileUrlTemplate, + tileProvider: tileCache.tileProvider, + userAgentPackageName: + MapTileCacheService.userAgentPackageName, + maxZoom: 19, + ), + if (polylines.isNotEmpty) PolylineLayer(polylines: polylines), + MarkerLayer( + markers: _buildHopMarkers(hops), + ), + ], ), - if (points.isEmpty) - Center( - child: Card( - color: Colors.white.withValues(alpha: 0.9), - child: const Padding( - padding: EdgeInsets.all(12), - child: Text('No repeater locations available for this path.'), + if (observedPaths.length > 1) + _buildPathSelector( + context, + observedPaths, + selectedIndex, + (index) { + setState(() { + _selectedPath = observedPaths[index].pathBytes; + }); + }, + ), + if (points.isEmpty) + Center( + child: Card( + color: Colors.white.withValues(alpha: 0.9), + child: const Padding( + padding: EdgeInsets.all(12), + child: Text('No repeater locations available for this path.'), + ), ), ), - ), - _buildLegendCard(context, hops), - ], + _buildLegendCard(context, hops), + ], + ), ), ); }, diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 051d584..45fea9a 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -38,92 +38,98 @@ class _ChannelsScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Channels'), - centerTitle: true, - automaticallyImplyLeading: !widget.hideBackButton, - actions: [ - IconButton( - icon: const Icon(Icons.tune), - tooltip: 'Settings', - onPressed: () => Navigator.push( - context, - MaterialPageRoute(builder: (context) => const SettingsScreen()), - ), - ), - IconButton( - icon: const Icon(Icons.bluetooth_disabled), - tooltip: 'Disconnect', - onPressed: () => _disconnect(context), - ), - IconButton( - icon: const Icon(Icons.refresh), - onPressed: () => context.read().getChannels(), - ), - ], - ), - body: Consumer( - builder: (context, connector, child) { - if (connector.isLoadingChannels) { - return const Center(child: CircularProgressIndicator()); - } + final connector = context.watch(); + final allowBack = !connector.isConnected; - final channels = connector.channels; - - if (channels.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.tag, size: 64, color: Colors.grey[400]), - const SizedBox(height: 16), - Text( - 'No channels configured', - style: TextStyle(fontSize: 16, color: Colors.grey[600]), - ), - const SizedBox(height: 24), - FilledButton.icon( - onPressed: () => _addPublicChannel(context, connector), - icon: const Icon(Icons.public), - label: const Text('Add Public Channel'), - ), - ], + return PopScope( + canPop: allowBack, + child: Scaffold( + appBar: AppBar( + title: const Text('Channels'), + centerTitle: true, + automaticallyImplyLeading: !widget.hideBackButton && allowBack, + actions: [ + IconButton( + icon: const Icon(Icons.tune), + tooltip: 'Settings', + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => const SettingsScreen()), ), - ); - } + ), + IconButton( + icon: const Icon(Icons.bluetooth_disabled), + tooltip: 'Disconnect', + onPressed: () => _disconnect(context), + ), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => context.read().getChannels(), + ), + ], + ), + body: Consumer( + builder: (context, connector, child) { + if (connector.isLoadingChannels) { + return const Center(child: CircularProgressIndicator()); + } - return ReorderableListView.builder( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 88), - buildDefaultDragHandles: false, - itemCount: channels.length, - onReorder: (oldIndex, newIndex) { - if (newIndex > oldIndex) newIndex -= 1; - final reordered = List.from(channels); - final item = reordered.removeAt(oldIndex); - reordered.insert(newIndex, item); - unawaited( - connector.setChannelOrder( - reordered.map((c) => c.index).toList(), + final channels = connector.channels; + + if (channels.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.tag, size: 64, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + 'No channels configured', + style: TextStyle(fontSize: 16, color: Colors.grey[600]), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: () => _addPublicChannel(context, connector), + icon: const Icon(Icons.public), + label: const Text('Add Public Channel'), + ), + ], ), ); - }, - itemBuilder: (context, index) { - final channel = channels[index]; - return _buildChannelTile(context, connector, channel, index); - }, - ); - }, - ), - floatingActionButton: FloatingActionButton( - onPressed: () => _showAddChannelDialog(context), - child: const Icon(Icons.add), - ), - bottomNavigationBar: SafeArea( - top: false, - child: QuickSwitchBar( - selectedIndex: 1, - onDestinationSelected: (index) => _handleQuickSwitch(index, context), + } + + return ReorderableListView.builder( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 88), + buildDefaultDragHandles: false, + itemCount: channels.length, + onReorder: (oldIndex, newIndex) { + if (newIndex > oldIndex) newIndex -= 1; + final reordered = List.from(channels); + final item = reordered.removeAt(oldIndex); + reordered.insert(newIndex, item); + unawaited( + connector.setChannelOrder( + reordered.map((c) => c.index).toList(), + ), + ); + }, + itemBuilder: (context, index) { + final channel = channels[index]; + return _buildChannelTile(context, connector, channel, index); + }, + ); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _showAddChannelDialog(context), + child: const Icon(Icons.add), + ), + bottomNavigationBar: SafeArea( + top: false, + child: QuickSwitchBar( + selectedIndex: 1, + onDestinationSelected: (index) => _handleQuickSwitch(index, context), + ), ), ), ); diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 79ee0ff..8bceace 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -82,6 +82,7 @@ class _ContactsScreenState extends State { @override Widget build(BuildContext context) { final connector = context.watch(); + final allowBack = !connector.isConnected; if (!connector.isConnected) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -93,145 +94,148 @@ class _ContactsScreenState extends State { final theme = Theme.of(context); - return Scaffold( - appBar: AppBar( - titleSpacing: 16, - centerTitle: false, - automaticallyImplyLeading: !widget.hideBackButton, - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Contacts'), - Text( - '${connector.contacts.length} contacts', - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, + return PopScope( + canPop: allowBack, + child: Scaffold( + appBar: AppBar( + titleSpacing: 16, + centerTitle: false, + automaticallyImplyLeading: !widget.hideBackButton && allowBack, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Contacts'), + Text( + '${connector.contacts.length} contacts', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), ), + ], + ), + actions: [ + IconButton( + icon: connector.isLoadingContacts + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + tooltip: 'Refresh', + onPressed: connector.isLoadingContacts ? null : () => connector.getContacts(), + ), + IconButton( + icon: const Icon(Icons.bluetooth_disabled), + tooltip: 'Disconnect', + onPressed: () => _disconnect(context, connector), + ), + IconButton( + icon: const Icon(Icons.tune), + tooltip: 'Settings', + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => const SettingsScreen()), + ), + ), + PopupMenuButton<_ContactMenuAction>( + tooltip: 'Contacts options', + onSelected: (action) { + switch (action) { + case _ContactMenuAction.sortRecentMessages: + setState(() { + _sortOption = ContactSortOption.recentMessages; + _forceLastSeenSort = false; + }); + break; + case _ContactMenuAction.sortName: + setState(() { + _sortOption = ContactSortOption.name; + _forceLastSeenSort = false; + }); + break; + case _ContactMenuAction.sortType: + setState(() { + _sortOption = ContactSortOption.type; + _forceLastSeenSort = false; + }); + break; + case _ContactMenuAction.toggleLastSeenFilter: + setState(() { + _forceLastSeenSort = !_forceLastSeenSort; + if (_forceLastSeenSort) { + _sortOption = ContactSortOption.lastSeen; + } + }); + break; + case _ContactMenuAction.toggleUnreadOnly: + setState(() { + _showUnreadOnly = !_showUnreadOnly; + }); + break; + case _ContactMenuAction.newGroup: + _showGroupEditor(context, connector.contacts); + break; + } + }, + itemBuilder: (context) { + final labelStyle = theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ); + return [ + PopupMenuItem<_ContactMenuAction>( + enabled: false, + child: Text('Sort by', style: labelStyle), + ), + CheckedPopupMenuItem<_ContactMenuAction>( + value: _ContactMenuAction.sortRecentMessages, + checked: _sortOption == ContactSortOption.recentMessages, + child: const Text('Recent messages'), + ), + CheckedPopupMenuItem<_ContactMenuAction>( + value: _ContactMenuAction.sortName, + checked: _sortOption == ContactSortOption.name, + child: const Text('Name'), + ), + CheckedPopupMenuItem<_ContactMenuAction>( + value: _ContactMenuAction.sortType, + checked: _sortOption == ContactSortOption.type, + child: const Text('Type'), + ), + const PopupMenuDivider(), + PopupMenuItem<_ContactMenuAction>( + enabled: false, + child: Text('Filters', style: labelStyle), + ), + CheckedPopupMenuItem<_ContactMenuAction>( + value: _ContactMenuAction.toggleLastSeenFilter, + checked: _forceLastSeenSort, + child: const Text('Last seen'), + ), + CheckedPopupMenuItem<_ContactMenuAction>( + value: _ContactMenuAction.toggleUnreadOnly, + checked: _showUnreadOnly, + child: const Text('Unread only'), + ), + PopupMenuItem<_ContactMenuAction>( + value: _ContactMenuAction.newGroup, + child: const Text('New group'), + ), + ]; + }, ), ], ), - actions: [ - IconButton( - icon: connector.isLoadingContacts - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.refresh), - tooltip: 'Refresh', - onPressed: connector.isLoadingContacts ? null : () => connector.getContacts(), + body: _buildContactsBody(context, connector), + bottomNavigationBar: SafeArea( + top: false, + child: QuickSwitchBar( + selectedIndex: 0, + onDestinationSelected: (index) => _handleQuickSwitch(index, context), ), - IconButton( - icon: const Icon(Icons.bluetooth_disabled), - tooltip: 'Disconnect', - onPressed: () => _disconnect(context, connector), - ), - IconButton( - icon: const Icon(Icons.tune), - tooltip: 'Settings', - onPressed: () => Navigator.push( - context, - MaterialPageRoute(builder: (context) => const SettingsScreen()), - ), - ), - PopupMenuButton<_ContactMenuAction>( - tooltip: 'Contacts options', - onSelected: (action) { - switch (action) { - case _ContactMenuAction.sortRecentMessages: - setState(() { - _sortOption = ContactSortOption.recentMessages; - _forceLastSeenSort = false; - }); - break; - case _ContactMenuAction.sortName: - setState(() { - _sortOption = ContactSortOption.name; - _forceLastSeenSort = false; - }); - break; - case _ContactMenuAction.sortType: - setState(() { - _sortOption = ContactSortOption.type; - _forceLastSeenSort = false; - }); - break; - case _ContactMenuAction.toggleLastSeenFilter: - setState(() { - _forceLastSeenSort = !_forceLastSeenSort; - if (_forceLastSeenSort) { - _sortOption = ContactSortOption.lastSeen; - } - }); - break; - case _ContactMenuAction.toggleUnreadOnly: - setState(() { - _showUnreadOnly = !_showUnreadOnly; - }); - break; - case _ContactMenuAction.newGroup: - _showGroupEditor(context, connector.contacts); - break; - } - }, - itemBuilder: (context) { - final labelStyle = theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ); - return [ - PopupMenuItem<_ContactMenuAction>( - enabled: false, - child: Text('Sort by', style: labelStyle), - ), - CheckedPopupMenuItem<_ContactMenuAction>( - value: _ContactMenuAction.sortRecentMessages, - checked: _sortOption == ContactSortOption.recentMessages, - child: const Text('Recent messages'), - ), - CheckedPopupMenuItem<_ContactMenuAction>( - value: _ContactMenuAction.sortName, - checked: _sortOption == ContactSortOption.name, - child: const Text('Name'), - ), - CheckedPopupMenuItem<_ContactMenuAction>( - value: _ContactMenuAction.sortType, - checked: _sortOption == ContactSortOption.type, - child: const Text('Type'), - ), - const PopupMenuDivider(), - PopupMenuItem<_ContactMenuAction>( - enabled: false, - child: Text('Filters', style: labelStyle), - ), - CheckedPopupMenuItem<_ContactMenuAction>( - value: _ContactMenuAction.toggleLastSeenFilter, - checked: _forceLastSeenSort, - child: const Text('Last seen'), - ), - CheckedPopupMenuItem<_ContactMenuAction>( - value: _ContactMenuAction.toggleUnreadOnly, - checked: _showUnreadOnly, - child: const Text('Unread only'), - ), - PopupMenuItem<_ContactMenuAction>( - value: _ContactMenuAction.newGroup, - child: const Text('New group'), - ), - ]; - }, - ), - ], - ), - body: _buildContactsBody(context, connector), - bottomNavigationBar: SafeArea( - top: false, - child: QuickSwitchBar( - selectedIndex: 0, - onDestinationSelected: (index) => _handleQuickSwitch(index, context), ), ), ); diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index f3c497d..b8699a0 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -130,113 +130,118 @@ class _MapScreenState extends State { center = highlightPosition; } - return Scaffold( - appBar: AppBar( - title: const Text('Node Map'), - centerTitle: true, - automaticallyImplyLeading: !widget.hideBackButton, - actions: [ - IconButton( - icon: const Icon(Icons.tune), - tooltip: 'Settings', - onPressed: () => Navigator.push( - context, - MaterialPageRoute(builder: (context) => const SettingsScreen()), + final allowBack = !connector.isConnected; + + return PopScope( + canPop: allowBack, + child: Scaffold( + appBar: AppBar( + title: const Text('Node Map'), + centerTitle: true, + automaticallyImplyLeading: !widget.hideBackButton && allowBack, + actions: [ + IconButton( + icon: const Icon(Icons.tune), + tooltip: 'Settings', + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => const SettingsScreen()), + ), ), - ), - IconButton( - icon: const Icon(Icons.bluetooth_disabled), - tooltip: 'Disconnect', - onPressed: () => _disconnect(context, connector), - ), - ], - ), - body: !hasMapContent - ? _buildEmptyState() - : Stack( - children: [ - FlutterMap( - mapController: _mapController, - options: MapOptions( - initialCenter: center, - initialZoom: 13.0, - minZoom: 2.0, - maxZoom: 18.0, - onTap: (_, latLng) { - if (_isSelectingPoi) { - setState(() { - _isSelectingPoi = false; - }); - _shareMarker( - context: context, - connector: connector, - position: latLng, - defaultLabel: 'Point of interest', - flags: 'poi', - ); - } - }, - onLongPress: (_, latLng) { - if (_isSelectingPoi) { - setState(() { - _isSelectingPoi = false; - }); - _shareMarker( - context: context, - connector: connector, - position: latLng, - defaultLabel: 'Point of interest', - flags: 'poi', - ); - return; - } - _showShareMarkerAtPositionSheet( - context: context, - connector: connector, - position: latLng, - ); - }, - ), - children: [ - TileLayer( - urlTemplate: kMapTileUrlTemplate, - tileProvider: tileCache.tileProvider, - userAgentPackageName: - MapTileCacheService.userAgentPackageName, - maxZoom: 19, - ), - MarkerLayer( - markers: [ - if (highlightPosition != null) - Marker( - point: highlightPosition, - width: 40, - height: 40, - child: Icon( - Icons.location_on_outlined, - color: Colors.red[600], - size: 34, - ), - ), - ..._buildMarkers(contactsWithLocation, settings), - ...sharedMarkers.map(_buildSharedMarker), - ], - ), - ], - ), - _buildLegend(contactsWithLocation.length, sharedMarkers.length), - ], + IconButton( + icon: const Icon(Icons.bluetooth_disabled), + tooltip: 'Disconnect', + onPressed: () => _disconnect(context, connector), ), - bottomNavigationBar: SafeArea( - top: false, - child: QuickSwitchBar( - selectedIndex: 2, - onDestinationSelected: (index) => _handleQuickSwitch(index, context), + ], + ), + body: !hasMapContent + ? _buildEmptyState() + : Stack( + children: [ + FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: center, + initialZoom: 13.0, + minZoom: 2.0, + maxZoom: 18.0, + onTap: (_, latLng) { + if (_isSelectingPoi) { + setState(() { + _isSelectingPoi = false; + }); + _shareMarker( + context: context, + connector: connector, + position: latLng, + defaultLabel: 'Point of interest', + flags: 'poi', + ); + } + }, + onLongPress: (_, latLng) { + if (_isSelectingPoi) { + setState(() { + _isSelectingPoi = false; + }); + _shareMarker( + context: context, + connector: connector, + position: latLng, + defaultLabel: 'Point of interest', + flags: 'poi', + ); + return; + } + _showShareMarkerAtPositionSheet( + context: context, + connector: connector, + position: latLng, + ); + }, + ), + children: [ + TileLayer( + urlTemplate: kMapTileUrlTemplate, + tileProvider: tileCache.tileProvider, + userAgentPackageName: + MapTileCacheService.userAgentPackageName, + maxZoom: 19, + ), + MarkerLayer( + markers: [ + if (highlightPosition != null) + Marker( + point: highlightPosition, + width: 40, + height: 40, + child: Icon( + Icons.location_on_outlined, + color: Colors.red[600], + size: 34, + ), + ), + ..._buildMarkers(contactsWithLocation, settings), + ...sharedMarkers.map(_buildSharedMarker), + ], + ), + ], + ), + _buildLegend(contactsWithLocation.length, sharedMarkers.length), + ], + ), + bottomNavigationBar: SafeArea( + top: false, + child: QuickSwitchBar( + selectedIndex: 2, + onDestinationSelected: (index) => _handleQuickSwitch(index, context), + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _showFilterDialog(context, settingsService), + child: const Icon(Icons.filter_list), ), - ), - floatingActionButton: FloatingActionButton( - onPressed: () => _showFilterDialog(context, settingsService), - child: const Icon(Icons.filter_list), ), ); }, diff --git a/lib/screens/repeater_hub_screen.dart b/lib/screens/repeater_hub_screen.dart index f28763e..a6125e2 100644 --- a/lib/screens/repeater_hub_screen.dart +++ b/lib/screens/repeater_hub_screen.dart @@ -31,116 +31,119 @@ class RepeaterHubScreen extends StatelessWidget { ), centerTitle: false, ), - body: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Repeater info card - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - CircleAvatar( - radius: 40, - backgroundColor: Colors.orange, - child: const Icon(Icons.cell_tower, size: 40, color: Colors.white), - ), - const SizedBox(height: 16), - Text( - repeater.name, - style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Text( - repeater.pathLabel, - style: TextStyle(fontSize: 14, color: Colors.grey[600]), - ), - if (repeater.hasLocation) ...[ - const SizedBox(height: 4), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.location_on, size: 14, color: Colors.grey[600]), - const SizedBox(width: 4), - Text( - '${repeater.latitude?.toStringAsFixed(4)}, ${repeater.longitude?.toStringAsFixed(4)}', - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - ], + body: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Repeater info card + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + CircleAvatar( + radius: 40, + backgroundColor: Colors.orange, + child: const Icon(Icons.cell_tower, size: 40, color: Colors.white), ), + const SizedBox(height: 16), + Text( + repeater.name, + style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + repeater.pathLabel, + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + if (repeater.hasLocation) ...[ + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.location_on, size: 14, color: Colors.grey[600]), + const SizedBox(width: 4), + Text( + '${repeater.latitude?.toStringAsFixed(4)}, ${repeater.longitude?.toStringAsFixed(4)}', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ), + ], ], - ], + ), ), ), - ), - const SizedBox(height: 24), - const Text( - 'Management Tools', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 16), - // Status button - _buildManagementCard( - context, - icon: Icons.analytics, - title: 'Status', - subtitle: 'View repeater status, stats, and neighbors', - color: Colors.blue, - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => RepeaterStatusScreen( - repeater: repeater, - password: password, + const SizedBox(height: 24), + const Text( + 'Management Tools', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + // Status button + _buildManagementCard( + context, + icon: Icons.analytics, + title: 'Status', + subtitle: 'View repeater status, stats, and neighbors', + color: Colors.blue, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RepeaterStatusScreen( + repeater: repeater, + password: password, + ), ), - ), - ); - }, - ), - const SizedBox(height: 12), - // CLI button - _buildManagementCard( - context, - icon: Icons.terminal, - title: 'CLI', - subtitle: 'Send commands to the repeater', - color: Colors.green, - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => RepeaterCliScreen( - repeater: repeater, - password: password, + ); + }, + ), + const SizedBox(height: 12), + // CLI button + _buildManagementCard( + context, + icon: Icons.terminal, + title: 'CLI', + subtitle: 'Send commands to the repeater', + color: Colors.green, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RepeaterCliScreen( + repeater: repeater, + password: password, + ), ), - ), - ); - }, - ), - const SizedBox(height: 12), - // Settings button - _buildManagementCard( - context, - icon: Icons.settings, - title: 'Settings', - subtitle: 'Configure repeater parameters', - color: Colors.orange, - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => RepeaterSettingsScreen( - repeater: repeater, - password: password, + ); + }, + ), + const SizedBox(height: 12), + // Settings button + _buildManagementCard( + context, + icon: Icons.settings, + title: 'Settings', + subtitle: 'Configure repeater parameters', + color: Colors.orange, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RepeaterSettingsScreen( + repeater: repeater, + password: password, + ), ), - ), - ); - }, - ), - ], + ); + }, + ), + ], + ), ), ), ); diff --git a/lib/screens/repeater_settings_screen.dart b/lib/screens/repeater_settings_screen.dart index 03e02c7..a0cb266 100644 --- a/lib/screens/repeater_settings_screen.dart +++ b/lib/screens/repeater_settings_screen.dart @@ -550,24 +550,27 @@ class _RepeaterSettingsScreenState extends State { ), ], ), - body: _isLoading && _nameController.text.isEmpty - ? const Center(child: CircularProgressIndicator()) - : ListView( - padding: const EdgeInsets.all(16), - children: [ - _buildBasicSettingsCard(), - const SizedBox(height: 16), - _buildRadioSettingsCard(), - const SizedBox(height: 16), - _buildLocationSettingsCard(), - const SizedBox(height: 16), - _buildFeatureTogglesCard(), - const SizedBox(height: 16), - _buildAdvertisementSettingsCard(), - const SizedBox(height: 32), - _buildDangerZoneCard(), - ], - ), + body: SafeArea( + top: false, + child: _isLoading && _nameController.text.isEmpty + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildBasicSettingsCard(), + const SizedBox(height: 16), + _buildRadioSettingsCard(), + const SizedBox(height: 16), + _buildLocationSettingsCard(), + const SizedBox(height: 16), + _buildFeatureTogglesCard(), + const SizedBox(height: 16), + _buildAdvertisementSettingsCard(), + const SizedBox(height: 32), + _buildDangerZoneCard(), + ], + ), + ), ); } diff --git a/lib/screens/repeater_status_screen.dart b/lib/screens/repeater_status_screen.dart index f27be22..447b3dd 100644 --- a/lib/screens/repeater_status_screen.dart +++ b/lib/screens/repeater_status_screen.dart @@ -296,17 +296,20 @@ class _RepeaterStatusScreenState extends State { ), ], ), - body: RefreshIndicator( - onRefresh: _loadStatus, - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - _buildSystemInfoCard(), - const SizedBox(height: 16), - _buildRadioStatsCard(), - const SizedBox(height: 16), - _buildPacketStatsCard(), - ], + body: SafeArea( + top: false, + child: RefreshIndicator( + onRefresh: _loadStatus, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildSystemInfoCard(), + const SizedBox(height: 16), + _buildRadioStatsCard(), + const SizedBox(height: 16), + _buildPacketStatsCard(), + ], + ), ), ), ); diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart index b0e31e1..a815a3a 100644 --- a/lib/screens/scanner_screen.dart +++ b/lib/screens/scanner_screen.dart @@ -18,20 +18,23 @@ class ScannerScreen extends StatelessWidget { centerTitle: true, automaticallyImplyLeading: false, ), - body: Consumer( - builder: (context, connector, child) { - return Column( - children: [ - // Status bar - _buildStatusBar(context, connector), - - // Device list - Expanded( - child: _buildDeviceList(context, connector), - ), - ], - ); - }, + body: SafeArea( + top: false, + child: Consumer( + builder: (context, connector, child) { + return Column( + children: [ + // Status bar + _buildStatusBar(context, connector), + + // Device list + Expanded( + child: _buildDeviceList(context, connector), + ), + ], + ); + }, + ), ), floatingActionButton: Consumer( builder: (context, connector, child) { diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 586a501..77a64d3 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -18,25 +18,28 @@ class SettingsScreen extends StatelessWidget { title: const Text('Settings'), centerTitle: true, ), - body: Consumer( - builder: (context, connector, child) { - return ListView( - padding: const EdgeInsets.all(16), - children: [ - _buildDeviceInfoCard(connector), - const SizedBox(height: 16), - _buildAppSettingsCard(context), - const SizedBox(height: 16), - _buildNodeSettingsCard(context, connector), - const SizedBox(height: 16), - _buildActionsCard(context, connector), - const SizedBox(height: 16), - _buildDebugCard(context), - const SizedBox(height: 16), - _buildAboutCard(context), - ], - ); - }, + body: SafeArea( + top: false, + child: Consumer( + builder: (context, connector, child) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildDeviceInfoCard(connector), + const SizedBox(height: 16), + _buildAppSettingsCard(context), + const SizedBox(height: 16), + _buildNodeSettingsCard(context, connector), + const SizedBox(height: 16), + _buildActionsCard(context, connector), + const SizedBox(height: 16), + _buildDebugCard(context), + const SizedBox(height: 16), + _buildAboutCard(context), + ], + ); + }, + ), ), ); } @@ -244,7 +247,7 @@ class SettingsScreen extends StatelessWidget { TextButton( onPressed: () async { Navigator.pop(context); - await connector.sendCliCommand('set name ${controller.text}'); + await connector.setNodeName(controller.text); await connector.refreshDeviceInfo(); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -302,18 +305,33 @@ class SettingsScreen extends StatelessWidget { TextButton( onPressed: () async { Navigator.pop(context); - var updated = false; - if (latController.text.isNotEmpty) { - await connector.sendCliCommand('set lat ${latController.text}'); - updated = true; + final latText = latController.text.trim(); + final lonText = lonController.text.trim(); + if (latText.isEmpty && lonText.isEmpty) { + return; } - if (lonController.text.isNotEmpty) { - await connector.sendCliCommand('set lon ${lonController.text}'); - updated = true; + + final currentLat = connector.selfLatitude; + final currentLon = connector.selfLongitude; + final lat = latText.isNotEmpty ? double.tryParse(latText) : currentLat; + final lon = lonText.isNotEmpty ? double.tryParse(lonText) : currentLon; + if (lat == null || lon == null) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enter both latitude and longitude.')), + ); + return; } - if (updated) { - await connector.refreshDeviceInfo(); + if (lat < -90 || lat > 90 || lon < -180 || lon > 180) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Invalid latitude or longitude.')), + ); + return; } + + await connector.setNodeLocation(lat: lat, lon: lon); + await connector.refreshDeviceInfo(); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Location updated')), @@ -340,7 +358,7 @@ class SettingsScreen extends StatelessWidget { TextButton( onPressed: () async { Navigator.pop(context); - await connector.sendCliCommand('set privacy on'); + await connector.setPrivacyMode(true); await connector.refreshDeviceInfo(); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -352,7 +370,7 @@ class SettingsScreen extends StatelessWidget { TextButton( onPressed: () async { Navigator.pop(context); - await connector.sendCliCommand('set privacy off'); + await connector.setPrivacyMode(false); await connector.refreshDeviceInfo(); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -367,7 +385,7 @@ class SettingsScreen extends StatelessWidget { } void _sendAdvert(BuildContext context, MeshCoreConnector connector) { - connector.sendCliCommand('advert'); + connector.sendSelfAdvert(flood: true); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Advertisement sent')), ); @@ -394,7 +412,7 @@ class SettingsScreen extends StatelessWidget { TextButton( onPressed: () { Navigator.pop(context); - connector.sendCliCommand('reboot'); + connector.rebootDevice(); }, child: const Text('Reboot', style: TextStyle(color: Colors.orange)), ),