chore/offband-rebrand
zach 7 months ago
parent a2cfae3a22
commit 6ff950d426

@ -613,10 +613,20 @@ class MeshCoreConnector extends ChangeNotifier {
throw Exception("MeshCore characteristics not found"); throw Exception("MeshCore characteristics not found");
} }
// Give the device a moment to be ready for descriptor writes // Retry setNotifyValue with increasing delays
await Future.delayed(const Duration(milliseconds: 300)); bool notifySet = false;
for (int attempt = 0; attempt < 3 && !notifySet; attempt++) {
await _txCharacteristic!.setNotifyValue(true); 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); _notifySubscription = _txCharacteristic!.onValueReceived.listen(_handleFrame);
_setState(MeshCoreConnectionState.connected); _setState(MeshCoreConnectionState.connected);
@ -1185,6 +1195,30 @@ class MeshCoreConnector extends ChangeNotifier {
await sendFrame(bytes); await sendFrame(bytes);
} }
Future<void> setNodeName(String name) async {
if (!isConnected) return;
await sendFrame(buildSetAdvertNameFrame(name));
}
Future<void> setNodeLocation({required double lat, required double lon}) async {
if (!isConnected) return;
await sendFrame(buildSetAdvertLatLonFrame(lat, lon));
}
Future<void> sendSelfAdvert({bool flood = true}) async {
if (!isConnected) return;
await sendFrame(buildSendSelfAdvertFrame(flood: flood));
}
Future<void> rebootDevice() async {
if (!isConnected) return;
await sendFrame(buildRebootFrame());
}
Future<void> setPrivacyMode(bool enabled) async {
await sendCliCommand('set privacy ${enabled ? 'on' : 'off'}');
}
Future<void> getChannels({int? maxChannels}) async { Future<void> getChannels({int? maxChannels}) async {
if (!isConnected) return; if (!isConnected) return;

@ -210,6 +210,11 @@ void writeUint32LE(Uint8List data, int offset, int value) {
data[offset + 3] = (value >> 24) & 0xFF; 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 // Helper to read null-terminated UTF-8 string
String readCString(Uint8List data, int offset, int maxLen) { String readCString(Uint8List data, int offset, int maxLen) {
int end = offset; int end = offset;
@ -362,6 +367,39 @@ Uint8List buildSetDeviceTimeFrame(int timestamp) {
return frame; 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 // Build CMD_SYNC_NEXT_MESSAGE frame
Uint8List buildSyncNextMessageFrame() { Uint8List buildSyncNextMessageFrame() {
return Uint8List.fromList([cmdSyncNextMessage]); return Uint8List.fromList([cmdSyncNextMessage]);

@ -16,23 +16,26 @@ class AppSettingsScreen extends StatelessWidget {
title: const Text('App Settings'), title: const Text('App Settings'),
centerTitle: true, centerTitle: true,
), ),
body: Consumer2<AppSettingsService, MeshCoreConnector>( body: SafeArea(
builder: (context, settingsService, connector, child) { top: false,
return ListView( child: Consumer2<AppSettingsService, MeshCoreConnector>(
padding: const EdgeInsets.all(16), builder: (context, settingsService, connector, child) {
children: [ return ListView(
_buildAppearanceCard(context, settingsService), padding: const EdgeInsets.all(16),
const SizedBox(height: 16), children: [
_buildNotificationsCard(context, settingsService), _buildAppearanceCard(context, settingsService),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildMessagingCard(context, settingsService), _buildNotificationsCard(context, settingsService),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildBatteryCard(context, settingsService, connector), _buildMessagingCard(context, settingsService),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildMapSettingsCard(context, settingsService), _buildBatteryCard(context, settingsService, connector),
], const SizedBox(height: 16),
); _buildMapSettingsCard(context, settingsService),
}, ],
);
},
),
), ),
); );
} }

@ -59,63 +59,66 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
), ),
], ],
), ),
body: Column( body: SafeArea(
children: [ top: false,
Padding( child: Column(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), children: [
child: SegmentedButton<_BleLogView>( Padding(
segments: const [ padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
ButtonSegment(value: _BleLogView.frames, label: Text('Frames')), child: SegmentedButton<_BleLogView>(
ButtonSegment(value: _BleLogView.rawLogRx, label: Text('Raw Log-RX')), segments: const [
], ButtonSegment(value: _BleLogView.frames, label: Text('Frames')),
selected: {_view}, ButtonSegment(value: _BleLogView.rawLogRx, label: Text('Raw Log-RX')),
onSelectionChanged: (selection) { ],
setState(() => _view = selection.first); selected: {_view},
}, onSelectionChanged: (selection) {
setState(() => _view = selection.first);
},
),
), ),
), const SizedBox(height: 8),
const SizedBox(height: 8), Expanded(
Expanded( child: hasEntries
child: hasEntries ? ListView.separated(
? ListView.separated( itemCount: showingFrames ? entries.length : rawEntries.length,
itemCount: showingFrames ? entries.length : rawEntries.length, separatorBuilder: (_, __) => const Divider(height: 1),
separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) {
itemBuilder: (context, index) { if (showingFrames) {
if (showingFrames) { final entry = entries[index];
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 = final time =
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}'; '${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
return ListTile( return ListTile(
dense: true, dense: true,
title: Text(entry.description), title: Text(info.title),
subtitle: Text('${entry.hexPreview}\n$time'), subtitle: Text('${info.summary}\n$time'),
isThreeLine: true, isThreeLine: true,
leading: Icon( leading: const Icon(Icons.download, size: 18),
entry.outgoing ? Icons.upload : Icons.download, onTap: () => _showRawDialog(context, info),
size: 18,
),
); );
} },
)
final entry = rawEntries[index]; : const Center(
final info = _decodeRawPacket(entry.payload); child: Text('No BLE activity yet'),
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'),
),
),
],
), ),
); );
}, },

@ -100,66 +100,69 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
), ),
centerTitle: false, centerTitle: false,
), ),
body: Column( body: SafeArea(
children: [ top: false,
Expanded( child: Column(
child: Consumer<MeshCoreConnector>( children: [
builder: (context, connector, child) { Expanded(
final messages = connector.getChannelMessages(widget.channel); child: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
WidgetsBinding.instance.addPostFrameCallback((_) { final messages = connector.getChannelMessages(widget.channel);
_scrollToBottom();
}); WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToBottom();
if (messages.isEmpty) { });
return Center(
child: Column( if (messages.isEmpty) {
mainAxisAlignment: MainAxisAlignment.center, return Center(
children: [ child: Column(
Icon( mainAxisAlignment: MainAxisAlignment.center,
widget.channel.isPublicChannel children: [
? Icons.public Icon(
: Icons.tag, widget.channel.isPublicChannel
size: 64, ? Icons.public
color: Colors.grey[400], : Icons.tag,
), size: 64,
const SizedBox(height: 16), color: Colors.grey[400],
Text(
'No messages yet',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
), ),
), const SizedBox(height: 16),
const SizedBox(height: 8), Text(
Text( 'No messages yet',
'Send a message to get started', style: TextStyle(
style: TextStyle( fontSize: 16,
fontSize: 14, color: Colors.grey[600],
color: Colors.grey[500], ),
), ),
), 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(), ],
], ),
), ),
); );
} }

@ -48,33 +48,36 @@ class ChannelMessagePathScreen extends StatelessWidget {
), ),
], ],
), ),
body: ListView( body: SafeArea(
padding: const EdgeInsets.all(16), top: false,
children: [ child: ListView(
_buildSummaryCard(context, observedLabel: observedLabel), padding: const EdgeInsets.all(16),
const SizedBox(height: 16), children: [
if (extraPaths.isNotEmpty) ...[ _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( Text(
'Other Observed Paths', 'Repeater Hops',
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildPathVariants(context, extraPaths), if (!hasHopDetails)
const SizedBox(height: 16), 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<ChannelMessagePathMapScree
appBar: AppBar( appBar: AppBar(
title: const Text('Path Map'), title: const Text('Path Map'),
), ),
body: Stack( body: SafeArea(
children: [ top: false,
FlutterMap( child: Stack(
key: mapKey, children: [
options: MapOptions( FlutterMap(
initialCenter: initialCenter, key: mapKey,
initialZoom: initialZoom, options: MapOptions(
initialCameraFit: bounds == null initialCenter: initialCenter,
? null initialZoom: initialZoom,
: CameraFit.bounds( initialCameraFit: bounds == null
bounds: bounds, ? null
padding: const EdgeInsets.all(64), : CameraFit.bounds(
maxZoom: 16, bounds: bounds,
), padding: const EdgeInsets.all(64),
minZoom: 2.0, maxZoom: 16,
maxZoom: 18.0, ),
), minZoom: 2.0,
children: [ maxZoom: 18.0,
TileLayer(
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName:
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
if (polylines.isNotEmpty) PolylineLayer(polylines: polylines),
MarkerLayer(
markers: _buildHopMarkers(hops),
), ),
], children: [
), TileLayer(
if (observedPaths.length > 1) urlTemplate: kMapTileUrlTemplate,
_buildPathSelector( tileProvider: tileCache.tileProvider,
context, userAgentPackageName:
observedPaths, MapTileCacheService.userAgentPackageName,
selectedIndex, maxZoom: 19,
(index) { ),
setState(() { if (polylines.isNotEmpty) PolylineLayer(polylines: polylines),
_selectedPath = observedPaths[index].pathBytes; MarkerLayer(
}); markers: _buildHopMarkers(hops),
}, ),
],
), ),
if (points.isEmpty) if (observedPaths.length > 1)
Center( _buildPathSelector(
child: Card( context,
color: Colors.white.withValues(alpha: 0.9), observedPaths,
child: const Padding( selectedIndex,
padding: EdgeInsets.all(12), (index) {
child: Text('No repeater locations available for this path.'), 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), ],
], ),
), ),
); );
}, },

@ -38,92 +38,98 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( final connector = context.watch<MeshCoreConnector>();
appBar: AppBar( final allowBack = !connector.isConnected;
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<MeshCoreConnector>().getChannels(),
),
],
),
body: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
if (connector.isLoadingChannels) {
return const Center(child: CircularProgressIndicator());
}
final channels = connector.channels; return PopScope(
canPop: allowBack,
if (channels.isEmpty) { child: Scaffold(
return Center( appBar: AppBar(
child: Column( title: const Text('Channels'),
mainAxisAlignment: MainAxisAlignment.center, centerTitle: true,
children: [ automaticallyImplyLeading: !widget.hideBackButton && allowBack,
Icon(Icons.tag, size: 64, color: Colors.grey[400]), actions: [
const SizedBox(height: 16), IconButton(
Text( icon: const Icon(Icons.tune),
'No channels configured', tooltip: 'Settings',
style: TextStyle(fontSize: 16, color: Colors.grey[600]), onPressed: () => Navigator.push(
), context,
const SizedBox(height: 24), MaterialPageRoute(builder: (context) => const SettingsScreen()),
FilledButton.icon(
onPressed: () => _addPublicChannel(context, connector),
icon: const Icon(Icons.public),
label: const Text('Add Public Channel'),
),
],
), ),
); ),
} IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
onPressed: () => _disconnect(context),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => context.read<MeshCoreConnector>().getChannels(),
),
],
),
body: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
if (connector.isLoadingChannels) {
return const Center(child: CircularProgressIndicator());
}
return ReorderableListView.builder( final channels = connector.channels;
padding: const EdgeInsets.fromLTRB(16, 8, 16, 88),
buildDefaultDragHandles: false, if (channels.isEmpty) {
itemCount: channels.length, return Center(
onReorder: (oldIndex, newIndex) { child: Column(
if (newIndex > oldIndex) newIndex -= 1; mainAxisAlignment: MainAxisAlignment.center,
final reordered = List<Channel>.from(channels); children: [
final item = reordered.removeAt(oldIndex); Icon(Icons.tag, size: 64, color: Colors.grey[400]),
reordered.insert(newIndex, item); const SizedBox(height: 16),
unawaited( Text(
connector.setChannelOrder( 'No channels configured',
reordered.map((c) => c.index).toList(), 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 ReorderableListView.builder(
return _buildChannelTile(context, connector, channel, index); padding: const EdgeInsets.fromLTRB(16, 8, 16, 88),
}, buildDefaultDragHandles: false,
); itemCount: channels.length,
}, onReorder: (oldIndex, newIndex) {
), if (newIndex > oldIndex) newIndex -= 1;
floatingActionButton: FloatingActionButton( final reordered = List<Channel>.from(channels);
onPressed: () => _showAddChannelDialog(context), final item = reordered.removeAt(oldIndex);
child: const Icon(Icons.add), reordered.insert(newIndex, item);
), unawaited(
bottomNavigationBar: SafeArea( connector.setChannelOrder(
top: false, reordered.map((c) => c.index).toList(),
child: QuickSwitchBar( ),
selectedIndex: 1, );
onDestinationSelected: (index) => _handleQuickSwitch(index, context), },
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),
),
), ),
), ),
); );

@ -82,6 +82,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>(); final connector = context.watch<MeshCoreConnector>();
final allowBack = !connector.isConnected;
if (!connector.isConnected) { if (!connector.isConnected) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@ -93,145 +94,148 @@ class _ContactsScreenState extends State<ContactsScreen> {
final theme = Theme.of(context); final theme = Theme.of(context);
return Scaffold( return PopScope(
appBar: AppBar( canPop: allowBack,
titleSpacing: 16, child: Scaffold(
centerTitle: false, appBar: AppBar(
automaticallyImplyLeading: !widget.hideBackButton, titleSpacing: 16,
title: Column( centerTitle: false,
crossAxisAlignment: CrossAxisAlignment.start, automaticallyImplyLeading: !widget.hideBackButton && allowBack,
mainAxisSize: MainAxisSize.min, title: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
const Text('Contacts'), mainAxisSize: MainAxisSize.min,
Text( children: [
'${connector.contacts.length} contacts', const Text('Contacts'),
style: theme.textTheme.labelSmall?.copyWith( Text(
color: theme.colorScheme.onSurfaceVariant, '${connector.contacts.length} contacts',
fontWeight: FontWeight.w600, 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: [ body: _buildContactsBody(context, connector),
IconButton( bottomNavigationBar: SafeArea(
icon: connector.isLoadingContacts top: false,
? const SizedBox( child: QuickSwitchBar(
width: 20, selectedIndex: 0,
height: 20, onDestinationSelected: (index) => _handleQuickSwitch(index, context),
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'),
),
];
},
),
],
),
body: _buildContactsBody(context, connector),
bottomNavigationBar: SafeArea(
top: false,
child: QuickSwitchBar(
selectedIndex: 0,
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
), ),
), ),
); );

@ -130,113 +130,118 @@ class _MapScreenState extends State<MapScreen> {
center = highlightPosition; center = highlightPosition;
} }
return Scaffold( final allowBack = !connector.isConnected;
appBar: AppBar(
title: const Text('Node Map'), return PopScope(
centerTitle: true, canPop: allowBack,
automaticallyImplyLeading: !widget.hideBackButton, child: Scaffold(
actions: [ appBar: AppBar(
IconButton( title: const Text('Node Map'),
icon: const Icon(Icons.tune), centerTitle: true,
tooltip: 'Settings', automaticallyImplyLeading: !widget.hideBackButton && allowBack,
onPressed: () => Navigator.push( actions: [
context, IconButton(
MaterialPageRoute(builder: (context) => const SettingsScreen()), icon: const Icon(Icons.tune),
tooltip: 'Settings',
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
), ),
), IconButton(
IconButton( icon: const Icon(Icons.bluetooth_disabled),
icon: const Icon(Icons.bluetooth_disabled), tooltip: 'Disconnect',
tooltip: 'Disconnect', onPressed: () => _disconnect(context, connector),
onPressed: () => _disconnect(context, connector), ),
), ],
], ),
), body: !hasMapContent
body: !hasMapContent ? _buildEmptyState()
? _buildEmptyState() : Stack(
: Stack( children: [
children: [ FlutterMap(
FlutterMap( mapController: _mapController,
mapController: _mapController, options: MapOptions(
options: MapOptions( initialCenter: center,
initialCenter: center, initialZoom: 13.0,
initialZoom: 13.0, minZoom: 2.0,
minZoom: 2.0, maxZoom: 18.0,
maxZoom: 18.0, onTap: (_, latLng) {
onTap: (_, latLng) { if (_isSelectingPoi) {
if (_isSelectingPoi) { setState(() {
setState(() { _isSelectingPoi = false;
_isSelectingPoi = false; });
}); _shareMarker(
_shareMarker( context: context,
context: context, connector: connector,
connector: connector, position: latLng,
position: latLng, defaultLabel: 'Point of interest',
defaultLabel: 'Point of interest', flags: 'poi',
flags: 'poi', );
); }
} },
}, onLongPress: (_, latLng) {
onLongPress: (_, latLng) { if (_isSelectingPoi) {
if (_isSelectingPoi) { setState(() {
setState(() { _isSelectingPoi = false;
_isSelectingPoi = false; });
}); _shareMarker(
_shareMarker( context: context,
connector: connector,
position: latLng,
defaultLabel: 'Point of interest',
flags: 'poi',
);
return;
}
_showShareMarkerAtPositionSheet(
context: context, context: context,
connector: connector, connector: connector,
position: latLng, 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( children: [
markers: [ TileLayer(
if (highlightPosition != null) urlTemplate: kMapTileUrlTemplate,
Marker( tileProvider: tileCache.tileProvider,
point: highlightPosition, userAgentPackageName:
width: 40, MapTileCacheService.userAgentPackageName,
height: 40, maxZoom: 19,
child: Icon( ),
Icons.location_on_outlined, MarkerLayer(
color: Colors.red[600], markers: [
size: 34, 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),
..._buildMarkers(contactsWithLocation, settings), ...sharedMarkers.map(_buildSharedMarker),
...sharedMarkers.map(_buildSharedMarker), ],
], ),
), ],
], ),
), _buildLegend(contactsWithLocation.length, sharedMarkers.length),
_buildLegend(contactsWithLocation.length, sharedMarkers.length), ],
], ),
), bottomNavigationBar: SafeArea(
bottomNavigationBar: SafeArea( top: false,
top: false, child: QuickSwitchBar(
child: QuickSwitchBar( selectedIndex: 2,
selectedIndex: 2, onDestinationSelected: (index) => _handleQuickSwitch(index, context),
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),
), ),
); );
}, },

@ -31,116 +31,119 @@ class RepeaterHubScreen extends StatelessWidget {
), ),
centerTitle: false, centerTitle: false,
), ),
body: Padding( body: SafeArea(
padding: const EdgeInsets.all(16), top: false,
child: Column( child: Padding(
crossAxisAlignment: CrossAxisAlignment.stretch, padding: const EdgeInsets.all(16),
children: [ child: Column(
// Repeater info card crossAxisAlignment: CrossAxisAlignment.stretch,
Card( children: [
child: Padding( // Repeater info card
padding: const EdgeInsets.all(16), Card(
child: Column( child: Padding(
children: [ padding: const EdgeInsets.all(16),
CircleAvatar( child: Column(
radius: 40, children: [
backgroundColor: Colors.orange, CircleAvatar(
child: const Icon(Icons.cell_tower, size: 40, color: Colors.white), radius: 40,
), backgroundColor: Colors.orange,
const SizedBox(height: 16), child: const Icon(Icons.cell_tower, size: 40, color: Colors.white),
Text( ),
repeater.name, const SizedBox(height: 16),
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), Text(
), repeater.name,
const SizedBox(height: 8), style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
Text( ),
repeater.pathLabel, const SizedBox(height: 8),
style: TextStyle(fontSize: 14, color: Colors.grey[600]), Text(
), repeater.pathLabel,
if (repeater.hasLocation) ...[ style: TextStyle(fontSize: 14, color: Colors.grey[600]),
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]),
),
],
), ),
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 SizedBox(height: 24), const Text(
const Text( 'Management Tools',
'Management Tools', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ),
), const SizedBox(height: 16),
const SizedBox(height: 16), // Status button
// Status button _buildManagementCard(
_buildManagementCard( context,
context, icon: Icons.analytics,
icon: Icons.analytics, title: 'Status',
title: 'Status', subtitle: 'View repeater status, stats, and neighbors',
subtitle: 'View repeater status, stats, and neighbors', color: Colors.blue,
color: Colors.blue, onTap: () {
onTap: () { Navigator.push(
Navigator.push( context,
context, MaterialPageRoute(
MaterialPageRoute( builder: (context) => RepeaterStatusScreen(
builder: (context) => RepeaterStatusScreen( repeater: repeater,
repeater: repeater, password: password,
password: password, ),
), ),
), );
); },
}, ),
), const SizedBox(height: 12),
const SizedBox(height: 12), // CLI button
// CLI button _buildManagementCard(
_buildManagementCard( context,
context, icon: Icons.terminal,
icon: Icons.terminal, title: 'CLI',
title: 'CLI', subtitle: 'Send commands to the repeater',
subtitle: 'Send commands to the repeater', color: Colors.green,
color: Colors.green, onTap: () {
onTap: () { Navigator.push(
Navigator.push( context,
context, MaterialPageRoute(
MaterialPageRoute( builder: (context) => RepeaterCliScreen(
builder: (context) => RepeaterCliScreen( repeater: repeater,
repeater: repeater, password: password,
password: password, ),
), ),
), );
); },
}, ),
), const SizedBox(height: 12),
const SizedBox(height: 12), // Settings button
// Settings button _buildManagementCard(
_buildManagementCard( context,
context, icon: Icons.settings,
icon: Icons.settings, title: 'Settings',
title: 'Settings', subtitle: 'Configure repeater parameters',
subtitle: 'Configure repeater parameters', color: Colors.orange,
color: Colors.orange, onTap: () {
onTap: () { Navigator.push(
Navigator.push( context,
context, MaterialPageRoute(
MaterialPageRoute( builder: (context) => RepeaterSettingsScreen(
builder: (context) => RepeaterSettingsScreen( repeater: repeater,
repeater: repeater, password: password,
password: password, ),
), ),
), );
); },
}, ),
), ],
], ),
), ),
), ),
); );

@ -550,24 +550,27 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
), ),
], ],
), ),
body: _isLoading && _nameController.text.isEmpty body: SafeArea(
? const Center(child: CircularProgressIndicator()) top: false,
: ListView( child: _isLoading && _nameController.text.isEmpty
padding: const EdgeInsets.all(16), ? const Center(child: CircularProgressIndicator())
children: [ : ListView(
_buildBasicSettingsCard(), padding: const EdgeInsets.all(16),
const SizedBox(height: 16), children: [
_buildRadioSettingsCard(), _buildBasicSettingsCard(),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildLocationSettingsCard(), _buildRadioSettingsCard(),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildFeatureTogglesCard(), _buildLocationSettingsCard(),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildAdvertisementSettingsCard(), _buildFeatureTogglesCard(),
const SizedBox(height: 32), const SizedBox(height: 16),
_buildDangerZoneCard(), _buildAdvertisementSettingsCard(),
], const SizedBox(height: 32),
), _buildDangerZoneCard(),
],
),
),
); );
} }

@ -296,17 +296,20 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
), ),
], ],
), ),
body: RefreshIndicator( body: SafeArea(
onRefresh: _loadStatus, top: false,
child: ListView( child: RefreshIndicator(
padding: const EdgeInsets.all(16), onRefresh: _loadStatus,
children: [ child: ListView(
_buildSystemInfoCard(), padding: const EdgeInsets.all(16),
const SizedBox(height: 16), children: [
_buildRadioStatsCard(), _buildSystemInfoCard(),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildPacketStatsCard(), _buildRadioStatsCard(),
], const SizedBox(height: 16),
_buildPacketStatsCard(),
],
),
), ),
), ),
); );

@ -18,20 +18,23 @@ class ScannerScreen extends StatelessWidget {
centerTitle: true, centerTitle: true,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
), ),
body: Consumer<MeshCoreConnector>( body: SafeArea(
builder: (context, connector, child) { top: false,
return Column( child: Consumer<MeshCoreConnector>(
children: [ builder: (context, connector, child) {
// Status bar return Column(
_buildStatusBar(context, connector), children: [
// Status bar
_buildStatusBar(context, connector),
// Device list // Device list
Expanded( Expanded(
child: _buildDeviceList(context, connector), child: _buildDeviceList(context, connector),
), ),
], ],
); );
}, },
),
), ),
floatingActionButton: Consumer<MeshCoreConnector>( floatingActionButton: Consumer<MeshCoreConnector>(
builder: (context, connector, child) { builder: (context, connector, child) {

@ -18,25 +18,28 @@ class SettingsScreen extends StatelessWidget {
title: const Text('Settings'), title: const Text('Settings'),
centerTitle: true, centerTitle: true,
), ),
body: Consumer<MeshCoreConnector>( body: SafeArea(
builder: (context, connector, child) { top: false,
return ListView( child: Consumer<MeshCoreConnector>(
padding: const EdgeInsets.all(16), builder: (context, connector, child) {
children: [ return ListView(
_buildDeviceInfoCard(connector), padding: const EdgeInsets.all(16),
const SizedBox(height: 16), children: [
_buildAppSettingsCard(context), _buildDeviceInfoCard(connector),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildNodeSettingsCard(context, connector), _buildAppSettingsCard(context),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildActionsCard(context, connector), _buildNodeSettingsCard(context, connector),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildDebugCard(context), _buildActionsCard(context, connector),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildAboutCard(context), _buildDebugCard(context),
], const SizedBox(height: 16),
); _buildAboutCard(context),
}, ],
);
},
),
), ),
); );
} }
@ -244,7 +247,7 @@ class SettingsScreen extends StatelessWidget {
TextButton( TextButton(
onPressed: () async { onPressed: () async {
Navigator.pop(context); Navigator.pop(context);
await connector.sendCliCommand('set name ${controller.text}'); await connector.setNodeName(controller.text);
await connector.refreshDeviceInfo(); await connector.refreshDeviceInfo();
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -302,18 +305,33 @@ class SettingsScreen extends StatelessWidget {
TextButton( TextButton(
onPressed: () async { onPressed: () async {
Navigator.pop(context); Navigator.pop(context);
var updated = false; final latText = latController.text.trim();
if (latController.text.isNotEmpty) { final lonText = lonController.text.trim();
await connector.sendCliCommand('set lat ${latController.text}'); if (latText.isEmpty && lonText.isEmpty) {
updated = true; return;
} }
if (lonController.text.isNotEmpty) {
await connector.sendCliCommand('set lon ${lonController.text}'); final currentLat = connector.selfLatitude;
updated = true; 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) { if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
await connector.refreshDeviceInfo(); 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; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Location updated')), const SnackBar(content: Text('Location updated')),
@ -340,7 +358,7 @@ class SettingsScreen extends StatelessWidget {
TextButton( TextButton(
onPressed: () async { onPressed: () async {
Navigator.pop(context); Navigator.pop(context);
await connector.sendCliCommand('set privacy on'); await connector.setPrivacyMode(true);
await connector.refreshDeviceInfo(); await connector.refreshDeviceInfo();
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -352,7 +370,7 @@ class SettingsScreen extends StatelessWidget {
TextButton( TextButton(
onPressed: () async { onPressed: () async {
Navigator.pop(context); Navigator.pop(context);
await connector.sendCliCommand('set privacy off'); await connector.setPrivacyMode(false);
await connector.refreshDeviceInfo(); await connector.refreshDeviceInfo();
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -367,7 +385,7 @@ class SettingsScreen extends StatelessWidget {
} }
void _sendAdvert(BuildContext context, MeshCoreConnector connector) { void _sendAdvert(BuildContext context, MeshCoreConnector connector) {
connector.sendCliCommand('advert'); connector.sendSelfAdvert(flood: true);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Advertisement sent')), const SnackBar(content: Text('Advertisement sent')),
); );
@ -394,7 +412,7 @@ class SettingsScreen extends StatelessWidget {
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
connector.sendCliCommand('reboot'); connector.rebootDevice();
}, },
child: const Text('Reboot', style: TextStyle(color: Colors.orange)), child: const Text('Reboot', style: TextStyle(color: Colors.orange)),
), ),

Loading…
Cancel
Save

Powered by TurnKey Linux.