mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
fixes
This commit is contained in:
parent
a2cfae3a22
commit
6ff950d426
14 changed files with 827 additions and 695 deletions
|
|
@ -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<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 {
|
||||
if (!isConnected) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -16,23 +16,26 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
title: const Text('App Settings'),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Consumer2<AppSettingsService, MeshCoreConnector>(
|
||||
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<AppSettingsService, MeshCoreConnector>(
|
||||
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),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,63 +59,66 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -100,66 +100,69 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
),
|
||||
centerTitle: false,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, child) {
|
||||
final messages = connector.getChannelMessages(widget.channel);
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Consumer<MeshCoreConnector>(
|
||||
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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ChannelMessagePathMapScree
|
|||
appBar: AppBar(
|
||||
title: const Text('Path Map'),
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
FlutterMap(
|
||||
key: mapKey,
|
||||
options: MapOptions(
|
||||
initialCenter: initialCenter,
|
||||
initialZoom: initialZoom,
|
||||
initialCameraFit: bounds == null
|
||||
? null
|
||||
: CameraFit.bounds(
|
||||
bounds: bounds,
|
||||
padding: const EdgeInsets.all(64),
|
||||
maxZoom: 16,
|
||||
),
|
||||
minZoom: 2.0,
|
||||
maxZoom: 18.0,
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: kMapTileUrlTemplate,
|
||||
tileProvider: tileCache.tileProvider,
|
||||
userAgentPackageName:
|
||||
MapTileCacheService.userAgentPackageName,
|
||||
maxZoom: 19,
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Stack(
|
||||
children: [
|
||||
FlutterMap(
|
||||
key: mapKey,
|
||||
options: MapOptions(
|
||||
initialCenter: initialCenter,
|
||||
initialZoom: initialZoom,
|
||||
initialCameraFit: bounds == null
|
||||
? null
|
||||
: CameraFit.bounds(
|
||||
bounds: bounds,
|
||||
padding: const EdgeInsets.all(64),
|
||||
maxZoom: 16,
|
||||
),
|
||||
minZoom: 2.0,
|
||||
maxZoom: 18.0,
|
||||
),
|
||||
if (polylines.isNotEmpty) PolylineLayer(polylines: polylines),
|
||||
MarkerLayer(
|
||||
markers: _buildHopMarkers(hops),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (observedPaths.length > 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -38,92 +38,98 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
|
|||
|
||||
@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<MeshCoreConnector>().getChannels(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, child) {
|
||||
if (connector.isLoadingChannels) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
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<MeshCoreConnector>().getChannels(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Consumer<MeshCoreConnector>(
|
||||
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<Channel>.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<Channel>.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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final allowBack = !connector.isConnected;
|
||||
|
||||
if (!connector.isConnected) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
|
|
@ -93,145 +94,148 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -130,113 +130,118 @@ class _MapScreenState extends State<MapScreen> {
|
|||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -550,24 +550,27 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -296,17 +296,20 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,20 +18,23 @@ class ScannerScreen extends StatelessWidget {
|
|||
centerTitle: true,
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
body: Consumer<MeshCoreConnector>(
|
||||
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<MeshCoreConnector>(
|
||||
builder: (context, connector, child) {
|
||||
return Column(
|
||||
children: [
|
||||
// Status bar
|
||||
_buildStatusBar(context, connector),
|
||||
|
||||
// Device list
|
||||
Expanded(
|
||||
child: _buildDeviceList(context, connector),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
floatingActionButton: Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, child) {
|
||||
|
|
|
|||
|
|
@ -18,25 +18,28 @@ class SettingsScreen extends StatelessWidget {
|
|||
title: const Text('Settings'),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Consumer<MeshCoreConnector>(
|
||||
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<MeshCoreConnector>(
|
||||
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)),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue