mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-25 01:59:04 +10:00
Initial commit: MeshCore Open Flutter client
Open-source Flutter client for MeshCore LoRa mesh networking devices. Features: - BLE device scanning and connection - Nordic UART Service (NUS) integration - Material 3 design with system theme support - Provider-based state management - Placeholder screens for chat, contacts, and settings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/notification_service.dart';
|
||||
|
||||
class AppSettingsScreen extends StatelessWidget {
|
||||
const AppSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
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),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppearanceCard(BuildContext context, AppSettingsService settingsService) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
'Appearance',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.brightness_6_outlined),
|
||||
title: const Text('Theme'),
|
||||
subtitle: Text(_themeModeLabel(settingsService.settings.themeMode)),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showThemeModeDialog(context, settingsService),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotificationsCard(BuildContext context, AppSettingsService settingsService) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
'Notifications',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.notifications_outlined),
|
||||
title: const Text('Enable Notifications'),
|
||||
subtitle: const Text('Receive notifications for messages and adverts'),
|
||||
value: settingsService.settings.notificationsEnabled,
|
||||
onChanged: (value) async {
|
||||
if (value) {
|
||||
// Request permission when enabling
|
||||
final granted = await NotificationService().requestPermissions();
|
||||
if (!granted) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Notification permission denied'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await settingsService.setNotificationsEnabled(value);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(value
|
||||
? 'Notifications enabled'
|
||||
: 'Notifications disabled'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
secondary: Icon(
|
||||
Icons.message_outlined,
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
'Message Notifications',
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Show notification when receiving new messages',
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
),
|
||||
),
|
||||
value: settingsService.settings.notifyOnNewMessage,
|
||||
onChanged: settingsService.settings.notificationsEnabled
|
||||
? (value) {
|
||||
settingsService.setNotifyOnNewMessage(value);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
secondary: Icon(
|
||||
Icons.cell_tower,
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
'Advertisement Notifications',
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Show notification when new nodes are discovered',
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
),
|
||||
),
|
||||
value: settingsService.settings.notifyOnNewAdvert,
|
||||
onChanged: settingsService.settings.notificationsEnabled
|
||||
? (value) {
|
||||
settingsService.setNotifyOnNewAdvert(value);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessagingCard(BuildContext context, AppSettingsService settingsService) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
'Messaging',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.refresh_outlined),
|
||||
title: const Text('Clear Path on Max Retry'),
|
||||
subtitle: const Text('Reset contact path after 5 failed send attempts'),
|
||||
value: settingsService.settings.clearPathOnMaxRetry,
|
||||
onChanged: (value) {
|
||||
settingsService.setClearPathOnMaxRetry(value);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(value
|
||||
? 'Paths will be cleared after 5 failed retries'
|
||||
: 'Paths will not be auto-cleared'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.alt_route),
|
||||
title: const Text('Auto Route Rotation'),
|
||||
subtitle: const Text('Cycle between best paths and flood mode'),
|
||||
value: settingsService.settings.autoRouteRotationEnabled,
|
||||
onChanged: (value) {
|
||||
settingsService.setAutoRouteRotationEnabled(value);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(value
|
||||
? 'Auto route rotation enabled'
|
||||
: 'Auto route rotation disabled'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMapSettingsCard(BuildContext context, AppSettingsService settingsService) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
'Map Display',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.router_outlined),
|
||||
title: const Text('Show Repeaters'),
|
||||
subtitle: const Text('Display repeater nodes on the map'),
|
||||
value: settingsService.settings.mapShowRepeaters,
|
||||
onChanged: (value) {
|
||||
settingsService.setMapShowRepeaters(value);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.chat_outlined),
|
||||
title: const Text('Show Chat Nodes'),
|
||||
subtitle: const Text('Display chat nodes on the map'),
|
||||
value: settingsService.settings.mapShowChatNodes,
|
||||
onChanged: (value) {
|
||||
settingsService.setMapShowChatNodes(value);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.people_outline),
|
||||
title: const Text('Show Other Nodes'),
|
||||
subtitle: const Text('Display other node types on the map'),
|
||||
value: settingsService.settings.mapShowOtherNodes,
|
||||
onChanged: (value) {
|
||||
settingsService.setMapShowOtherNodes(value);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.timer_outlined),
|
||||
title: const Text('Time Filter'),
|
||||
subtitle: Text(
|
||||
settingsService.settings.mapTimeFilterHours == 0
|
||||
? 'Show all nodes'
|
||||
: 'Show nodes from last ${settingsService.settings.mapTimeFilterHours.toInt()} hours',
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showTimeFilterDialog(context, settingsService),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBatteryCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
MeshCoreConnector connector,
|
||||
) {
|
||||
final deviceId = connector.device?.remoteId.toString();
|
||||
final isConnected = connector.isConnected && deviceId != null;
|
||||
final selection =
|
||||
isConnected ? settingsService.batteryChemistryForDevice(deviceId) : 'nmc';
|
||||
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
'Battery',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.battery_full),
|
||||
title: const Text('Battery Chemistry'),
|
||||
subtitle: Text(
|
||||
isConnected
|
||||
? 'Set per device (${connector.device!.platformName})'
|
||||
: 'Connect to a device to choose',
|
||||
),
|
||||
trailing: DropdownButton<String>(
|
||||
value: selection,
|
||||
onChanged: isConnected
|
||||
? (value) {
|
||||
if (value != null) {
|
||||
settingsService.setBatteryChemistryForDevice(deviceId, value);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 'nmc',
|
||||
child: Text('18650 NMC (3.0-4.2V)'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'lifepo4',
|
||||
child: Text('LiFePO4 (2.6-3.65V)'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'lipo',
|
||||
child: Text('LiPo (3.0-4.2V)'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showThemeModeDialog(BuildContext context, AppSettingsService settingsService) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Theme'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RadioListTile<String>(
|
||||
title: const Text('System default'),
|
||||
value: 'system',
|
||||
groupValue: settingsService.settings.themeMode,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsService.setThemeMode(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
RadioListTile<String>(
|
||||
title: const Text('Light'),
|
||||
value: 'light',
|
||||
groupValue: settingsService.settings.themeMode,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsService.setThemeMode(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
RadioListTile<String>(
|
||||
title: const Text('Dark'),
|
||||
value: 'dark',
|
||||
groupValue: settingsService.settings.themeMode,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsService.setThemeMode(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _themeModeLabel(String value) {
|
||||
switch (value) {
|
||||
case 'light':
|
||||
return 'Light';
|
||||
case 'dark':
|
||||
return 'Dark';
|
||||
default:
|
||||
return 'System default';
|
||||
}
|
||||
}
|
||||
|
||||
void _showTimeFilterDialog(BuildContext context, AppSettingsService settingsService) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Map Time Filter'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Show nodes discovered within:'),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: const Text('All time'),
|
||||
leading: Radio<double>(
|
||||
value: 0,
|
||||
groupValue: settingsService.settings.mapTimeFilterHours,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsService.setMapTimeFilterHours(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Last hour'),
|
||||
leading: Radio<double>(
|
||||
value: 1,
|
||||
groupValue: settingsService.settings.mapTimeFilterHours,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsService.setMapTimeFilterHours(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Last 6 hours'),
|
||||
leading: Radio<double>(
|
||||
value: 6,
|
||||
groupValue: settingsService.settings.mapTimeFilterHours,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsService.setMapTimeFilterHours(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Last 24 hours'),
|
||||
leading: Radio<double>(
|
||||
value: 24,
|
||||
groupValue: settingsService.settings.mapTimeFilterHours,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsService.setMapTimeFilterHours(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Last week'),
|
||||
leading: Radio<double>(
|
||||
value: 168,
|
||||
groupValue: settingsService.settings.mapTimeFilterHours,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsService.setMapTimeFilterHours(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../services/ble_debug_log_service.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
|
||||
enum _BleLogView { frames, rawLogRx }
|
||||
|
||||
class BleDebugLogScreen extends StatefulWidget {
|
||||
const BleDebugLogScreen({super.key});
|
||||
|
||||
@override
|
||||
State<BleDebugLogScreen> createState() => _BleDebugLogScreenState();
|
||||
}
|
||||
|
||||
class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
_BleLogView _view = _BleLogView.frames;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<BleDebugLogService>(
|
||||
builder: (context, logService, _) {
|
||||
final entries = logService.entries.reversed.toList();
|
||||
final rawEntries = logService.rawLogRxEntries.reversed.toList();
|
||||
final showingFrames = _view == _BleLogView.frames;
|
||||
final hasEntries = showingFrames ? entries.isNotEmpty : rawEntries.isNotEmpty;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('BLE Debug Log'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Copy log',
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: hasEntries
|
||||
? () async {
|
||||
final text = showingFrames
|
||||
? entries
|
||||
.map((entry) => '${entry.description}\n${entry.hexPreview}\n')
|
||||
.join('\n')
|
||||
: rawEntries
|
||||
.map((entry) => 'RX RAW_LOG_RX_DATA\n${entry.hexPreview}\n')
|
||||
.join('\n');
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('BLE log copied')),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Clear log',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: hasEntries
|
||||
? () {
|
||||
logService.clear();
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
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);
|
||||
},
|
||||
),
|
||||
),
|
||||
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(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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showRawDialog(BuildContext context, _RawPacketInfo info) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(info.title),
|
||||
content: SingleChildScrollView(
|
||||
child: SelectableText(info.rawHex),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_RawPacketInfo _decodeRawPacket(Uint8List raw) {
|
||||
if (raw.length < 2) {
|
||||
return _RawPacketInfo(
|
||||
title: 'RX RAW_LOG_RX_DATA • invalid',
|
||||
summary: 'Packet too short',
|
||||
rawHex: _bytesToHex(raw),
|
||||
);
|
||||
}
|
||||
|
||||
var index = 0;
|
||||
final header = raw[index++];
|
||||
final routeType = header & 0x03;
|
||||
final payloadType = (header >> 2) & 0x0F;
|
||||
final payloadVer = (header >> 6) & 0x03;
|
||||
final hasTransport = routeType == 0 || routeType == 3;
|
||||
if (hasTransport) {
|
||||
if (raw.length < index + 4) {
|
||||
return _RawPacketInfo(
|
||||
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
|
||||
summary: 'Missing transport codes',
|
||||
rawHex: _bytesToHex(raw),
|
||||
);
|
||||
}
|
||||
index += 4;
|
||||
}
|
||||
if (raw.length <= index) {
|
||||
return _RawPacketInfo(
|
||||
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
|
||||
summary: 'Missing path length',
|
||||
rawHex: _bytesToHex(raw),
|
||||
);
|
||||
}
|
||||
final pathLen = raw[index++];
|
||||
if (raw.length < index + pathLen) {
|
||||
return _RawPacketInfo(
|
||||
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
|
||||
summary: 'Truncated path',
|
||||
rawHex: _bytesToHex(raw),
|
||||
);
|
||||
}
|
||||
final pathBytes = raw.sublist(index, index + pathLen);
|
||||
index += pathLen;
|
||||
if (raw.length <= index) {
|
||||
return _RawPacketInfo(
|
||||
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
|
||||
summary: 'Missing payload',
|
||||
rawHex: _bytesToHex(raw),
|
||||
);
|
||||
}
|
||||
final payload = raw.sublist(index);
|
||||
|
||||
final title = 'RX ${_payloadTypeLabel(payloadType)} • ${_routeLabel(routeType)} • v$payloadVer';
|
||||
final summary = _decodePayloadSummary(payloadType, payload);
|
||||
final pathSummary = pathLen > 0 ? 'Path=${_bytesToHex(pathBytes)}' : 'Path=none';
|
||||
final detail = '$summary • $pathSummary • len=${raw.length}';
|
||||
return _RawPacketInfo(title: title, summary: detail, rawHex: _bytesToHex(raw));
|
||||
}
|
||||
|
||||
String _decodePayloadSummary(int payloadType, Uint8List payload) {
|
||||
switch (payloadType) {
|
||||
case 0x00: // REQ
|
||||
return 'REQ payload=${payload.length} bytes';
|
||||
case 0x01: // RESP
|
||||
return 'RESP payload=${payload.length} bytes';
|
||||
case 0x02: // TXT
|
||||
return 'TXT payload=${payload.length} bytes';
|
||||
case 0x03: // ACK
|
||||
if (payload.length < 4) return 'ACK (short)';
|
||||
return 'ACK crc=${_bytesToHex(payload.sublist(0, 4))}';
|
||||
case 0x04: // ADVERT
|
||||
return _decodeAdvertSummary(payload);
|
||||
case 0x05: // GROUP_TXT
|
||||
if (payload.length < 3) return 'GRP_TXT (short)';
|
||||
final channelHash = payload[0].toRadixString(16).padLeft(2, '0');
|
||||
final mac = _bytesToHex(payload.sublist(1, 3));
|
||||
final cipherLen = payload.length - 3;
|
||||
return 'GRP_TXT hash=$channelHash mac=$mac cipher=$cipherLen';
|
||||
case 0x06: // GROUP_DATA
|
||||
return 'GRP_DATA payload=${payload.length} bytes';
|
||||
case 0x07: // ANON_REQ
|
||||
return 'ANON_REQ payload=${payload.length} bytes';
|
||||
case 0x08: // PATH
|
||||
return 'PATH payload=${payload.length} bytes';
|
||||
case 0x09: // TRACE
|
||||
return 'TRACE payload=${payload.length} bytes';
|
||||
case 0x0A: // MULTIPART
|
||||
return 'MULTIPART payload=${payload.length} bytes';
|
||||
case 0x0B: // CONTROL
|
||||
return _decodeControlSummary(payload);
|
||||
case 0x0F: // RAW
|
||||
return 'RAW payload=${payload.length} bytes';
|
||||
default:
|
||||
return 'TYPE_$payloadType payload=${payload.length} bytes';
|
||||
}
|
||||
}
|
||||
|
||||
String _decodeAdvertSummary(Uint8List payload) {
|
||||
if (payload.length < 101) {
|
||||
return 'ADVERT (short)';
|
||||
}
|
||||
var offset = 0;
|
||||
final pubKey = _bytesToHex(payload.sublist(offset, offset + 32), spaced: false);
|
||||
offset += 32;
|
||||
final timestamp = readUint32LE(payload, offset);
|
||||
offset += 4;
|
||||
offset += 64; // signature
|
||||
final flags = payload[offset++];
|
||||
final role = _deviceRoleLabel(flags & 0x0F);
|
||||
final hasLocation = (flags & 0x10) != 0;
|
||||
final hasFeature1 = (flags & 0x20) != 0;
|
||||
final hasFeature2 = (flags & 0x40) != 0;
|
||||
final hasName = (flags & 0x80) != 0;
|
||||
String? name;
|
||||
double? lat;
|
||||
double? lon;
|
||||
if (hasLocation && payload.length >= offset + 8) {
|
||||
lat = readInt32LE(payload, offset) / 1000000.0;
|
||||
lon = readInt32LE(payload, offset + 4) / 1000000.0;
|
||||
offset += 8;
|
||||
}
|
||||
if (hasFeature1) offset += 2;
|
||||
if (hasFeature2) offset += 2;
|
||||
if (hasName && payload.length > offset) {
|
||||
final rawName = String.fromCharCodes(payload.sublist(offset));
|
||||
final nul = rawName.indexOf('\u0000');
|
||||
name = nul >= 0 ? rawName.substring(0, nul) : rawName;
|
||||
name = name.trim();
|
||||
}
|
||||
final namePart = (name != null && name.isNotEmpty) ? ' name="$name"' : '';
|
||||
final locPart = (lat != null && lon != null)
|
||||
? ' loc=${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}'
|
||||
: '';
|
||||
return 'ADVERT role=$role ts=$timestamp$namePart$locPart key=${pubKey.substring(0, 12)}…';
|
||||
}
|
||||
|
||||
String _decodeControlSummary(Uint8List payload) {
|
||||
if (payload.isEmpty) return 'CONTROL (empty)';
|
||||
final flags = payload[0];
|
||||
final subType = flags & 0xF0;
|
||||
if (subType == 0x80) {
|
||||
if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)';
|
||||
final typeFilter = payload[1];
|
||||
final tag = readUint32LE(payload, 2);
|
||||
final since = payload.length >= 10 ? readUint32LE(payload, 6) : 0;
|
||||
return 'CONTROL DISCOVER_REQ filter=0x${typeFilter.toRadixString(16).padLeft(2, '0')} tag=$tag since=$since';
|
||||
}
|
||||
if (subType == 0x90) {
|
||||
if (payload.length < 14) return 'CONTROL DISCOVER_RESP (short)';
|
||||
final nodeType = flags & 0x0F;
|
||||
final snrRaw = payload[1];
|
||||
final snrSigned = snrRaw > 127 ? snrRaw - 256 : snrRaw;
|
||||
final snr = snrSigned / 4.0;
|
||||
final tag = readUint32LE(payload, 2);
|
||||
final keyLen = payload.length - 6;
|
||||
return 'CONTROL DISCOVER_RESP node=${_deviceRoleLabel(nodeType)} snr=${snr.toStringAsFixed(2)} tag=$tag key=$keyLen';
|
||||
}
|
||||
return 'CONTROL subtype=0x${subType.toRadixString(16).padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _payloadTypeLabel(int payloadType) {
|
||||
switch (payloadType) {
|
||||
case 0x00:
|
||||
return 'REQ';
|
||||
case 0x01:
|
||||
return 'RESP';
|
||||
case 0x02:
|
||||
return 'TXT';
|
||||
case 0x03:
|
||||
return 'ACK';
|
||||
case 0x04:
|
||||
return 'ADVERT';
|
||||
case 0x05:
|
||||
return 'GRP_TXT';
|
||||
case 0x06:
|
||||
return 'GRP_DATA';
|
||||
case 0x07:
|
||||
return 'ANON_REQ';
|
||||
case 0x08:
|
||||
return 'PATH';
|
||||
case 0x09:
|
||||
return 'TRACE';
|
||||
case 0x0A:
|
||||
return 'MULTIPART';
|
||||
case 0x0B:
|
||||
return 'CONTROL';
|
||||
case 0x0F:
|
||||
return 'RAW';
|
||||
default:
|
||||
return 'TYPE_$payloadType';
|
||||
}
|
||||
}
|
||||
|
||||
String _routeLabel(int routeType) {
|
||||
switch (routeType) {
|
||||
case 0:
|
||||
return 'TRANS_FLOOD';
|
||||
case 1:
|
||||
return 'FLOOD';
|
||||
case 2:
|
||||
return 'DIRECT';
|
||||
case 3:
|
||||
return 'TRANS_DIRECT';
|
||||
default:
|
||||
return 'ROUTE_$routeType';
|
||||
}
|
||||
}
|
||||
|
||||
String _deviceRoleLabel(int role) {
|
||||
switch (role) {
|
||||
case 0x01:
|
||||
return 'Chat';
|
||||
case 0x02:
|
||||
return 'Repeater';
|
||||
case 0x03:
|
||||
return 'Room';
|
||||
case 0x04:
|
||||
return 'Sensor';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
String _bytesToHex(Uint8List bytes, {bool spaced = true}) {
|
||||
if (bytes.isEmpty) return '';
|
||||
if (!spaced) {
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
class _RawPacketInfo {
|
||||
final String title;
|
||||
final String summary;
|
||||
final String rawHex;
|
||||
|
||||
_RawPacketInfo({
|
||||
required this.title,
|
||||
required this.summary,
|
||||
required this.rawHex,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../models/channel.dart';
|
||||
import '../models/channel_message.dart';
|
||||
import '../utils/emoji_utils.dart';
|
||||
import '../widgets/gif_message.dart';
|
||||
import '../widgets/gif_picker.dart';
|
||||
import 'map_screen.dart';
|
||||
|
||||
class ChannelChatScreen extends StatefulWidget {
|
||||
final Channel channel;
|
||||
|
||||
const ChannelChatScreen({
|
||||
super.key,
|
||||
required this.channel,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChannelChatScreen> createState() => _ChannelChatScreenState();
|
||||
}
|
||||
|
||||
class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
widget.channel.isPublicChannel ? Icons.public : Icons.tag,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.channel.name.isEmpty
|
||||
? 'Channel ${widget.channel.index}'
|
||||
: widget.channel.name,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Text(
|
||||
widget.channel.isPublicChannel ? 'Public' : 'Private',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: false,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, child) {
|
||||
final messages = connector.getChannelMessages(widget.channel);
|
||||
|
||||
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],
|
||||
),
|
||||
),
|
||||
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);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_buildMessageComposer(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageBubble(ChannelMessage message) {
|
||||
final isOutgoing = message.isOutgoing;
|
||||
final gifId = _parseGifId(message.text);
|
||||
final poi = _parsePoiMessage(message.text);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOutgoing) ...[
|
||||
_buildAvatar(message.senderName),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
child: GestureDetector(
|
||||
onLongPress: () => _showMessagePathInfo(message),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.65,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isOutgoing
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOutgoing) ...[
|
||||
Text(
|
||||
message.senderName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
if (poi != null)
|
||||
_buildPoiMessage(context, poi, isOutgoing)
|
||||
else if (gifId != null)
|
||||
GifMessage(
|
||||
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
fallbackTextColor: isOutgoing
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.7)
|
||||
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
message.text,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
if (message.pathBytes.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'via ${_formatPathPrefixes(message.pathBytes)}',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(message.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
if (message.repeatCount > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(Icons.repeat, size: 12, color: Colors.grey[600]),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'${message.repeatCount}',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
if (isOutgoing) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
message.status == ChannelMessageStatus.sent
|
||||
? Icons.check
|
||||
: message.status == ChannelMessageStatus.pending
|
||||
? Icons.schedule
|
||||
: Icons.error_outline,
|
||||
size: 14,
|
||||
color: message.status == ChannelMessageStatus.failed
|
||||
? Colors.red
|
||||
: Colors.grey[600],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _parseGifId(String text) {
|
||||
final trimmed = text.trim();
|
||||
final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed);
|
||||
return match?.group(1);
|
||||
}
|
||||
|
||||
_PoiInfo? _parsePoiMessage(String text) {
|
||||
final trimmed = text.trim();
|
||||
final match = RegExp(r'm:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|').firstMatch(trimmed);
|
||||
if (match == null) return null;
|
||||
final lat = double.tryParse(match.group(1) ?? '');
|
||||
final lon = double.tryParse(match.group(2) ?? '');
|
||||
if (lat == null || lon == null) return null;
|
||||
final label = match.group(3) ?? '';
|
||||
return _PoiInfo(lat: lat, lon: lon, label: label);
|
||||
}
|
||||
|
||||
Widget _buildPoiMessage(BuildContext context, _PoiInfo poi, bool isOutgoing) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textColor =
|
||||
isOutgoing ? colorScheme.onPrimaryContainer : colorScheme.onSurface;
|
||||
final metaColor = textColor.withValues(alpha: 0.7);
|
||||
final channelColor = widget.channel.isPublicChannel ? Colors.orange : Colors.blue;
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.location_on_outlined, color: channelColor),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MapScreen(
|
||||
highlightPosition: LatLng(poi.lat, poi.lon),
|
||||
highlightLabel: poi.label,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'POI Shared',
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (poi.label.isNotEmpty)
|
||||
Text(
|
||||
poi.label,
|
||||
style: TextStyle(
|
||||
color: metaColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showGifPicker(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => GifPicker(
|
||||
onGifSelected: (gifId) {
|
||||
_textController.text = 'g:$gifId';
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar(String senderName) {
|
||||
final initial = _getFirstCharacterOrEmoji(senderName);
|
||||
final color = _getColorForName(senderName);
|
||||
|
||||
return CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: color.withValues(alpha: 0.2),
|
||||
child: Text(
|
||||
initial,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getFirstCharacterOrEmoji(String name) {
|
||||
if (name.isEmpty) return '?';
|
||||
|
||||
final emoji = firstEmoji(name);
|
||||
if (emoji != null) return emoji;
|
||||
|
||||
final runes = name.runes.toList();
|
||||
if (runes.isEmpty) return '?';
|
||||
return String.fromCharCode(runes[0]).toUpperCase();
|
||||
}
|
||||
|
||||
Color _getColorForName(String name) {
|
||||
// Generate a consistent color based on the name hash
|
||||
final hash = name.hashCode;
|
||||
final colors = [
|
||||
Colors.blue,
|
||||
Colors.green,
|
||||
Colors.orange,
|
||||
Colors.purple,
|
||||
Colors.pink,
|
||||
Colors.teal,
|
||||
Colors.indigo,
|
||||
Colors.cyan,
|
||||
Colors.amber,
|
||||
Colors.deepOrange,
|
||||
];
|
||||
|
||||
return colors[hash.abs() % colors.length];
|
||||
}
|
||||
|
||||
Widget _buildMessageComposer() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.gif_box),
|
||||
onPressed: () => _showGifPicker(context),
|
||||
tooltip: 'Send GIF',
|
||||
),
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _textController,
|
||||
builder: (context, value, child) {
|
||||
final gifId = _parseGifId(value.text);
|
||||
if (gifId != null) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GifMessage(
|
||||
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
fallbackTextColor:
|
||||
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
width: 160,
|
||||
height: 110,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => _textController.clear(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return TextField(
|
||||
controller: _textController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Type a message...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
maxLines: null,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: _sendMessage,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _sendMessage() {
|
||||
final text = _textController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
|
||||
context.read<MeshCoreConnector>().sendChannelMessage(widget.channel, text);
|
||||
_textController.clear();
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(time);
|
||||
|
||||
if (diff.inDays > 0) {
|
||||
return '${time.day}/${time.month} ${time.hour}:${time.minute.toString().padLeft(2, '0')}';
|
||||
} else {
|
||||
return '${time.hour}:${time.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
void _showMessagePathInfo(ChannelMessage message) {
|
||||
final pathPrefixes =
|
||||
message.pathBytes.isNotEmpty ? _formatPathPrefixes(message.pathBytes) : null;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Packet Path'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildDetailRow('Sender', message.senderName),
|
||||
_buildDetailRow('Time', _formatTime(message.timestamp)),
|
||||
_buildDetailRow('Repeats', message.repeatCount.toString()),
|
||||
_buildDetailRow('Path', _formatPathLabel(message.pathLength)),
|
||||
if (pathPrefixes != null) _buildDetailRow('Prefixes', pathPrefixes),
|
||||
if (pathPrefixes == null) ...[
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Hop details are not provided for this packet.',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatPathLabel(int? pathLength) {
|
||||
if (pathLength == null) return 'Unknown';
|
||||
if (pathLength < 0) return 'Flood';
|
||||
if (pathLength == 0) return 'Direct';
|
||||
return '$pathLength hops';
|
||||
}
|
||||
|
||||
String _formatPathPrefixes(Uint8List pathBytes) {
|
||||
return pathBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(',');
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 70,
|
||||
child: Text(label, style: TextStyle(color: Colors.grey[600])),
|
||||
),
|
||||
Expanded(child: Text(value)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PoiInfo {
|
||||
final double lat;
|
||||
final double lon;
|
||||
final String label;
|
||||
|
||||
const _PoiInfo({
|
||||
required this.lat,
|
||||
required this.lon,
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../models/channel.dart';
|
||||
import 'channel_chat_screen.dart';
|
||||
|
||||
class ChannelsScreen extends StatefulWidget {
|
||||
const ChannelsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ChannelsScreen> createState() => _ChannelsScreenState();
|
||||
}
|
||||
|
||||
class _ChannelsScreenState extends State<ChannelsScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<MeshCoreConnector>().getChannels();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Channels'),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
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;
|
||||
|
||||
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 ReorderableListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: channels.length,
|
||||
onReorder: (oldIndex, newIndex) async {
|
||||
if (newIndex > oldIndex) newIndex -= 1;
|
||||
final reordered = List<Channel>.from(channels);
|
||||
final item = reordered.removeAt(oldIndex);
|
||||
reordered.insert(newIndex, item);
|
||||
await connector.setChannelOrder(
|
||||
reordered.map((c) => c.index).toList(),
|
||||
);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final channel = channels[index];
|
||||
return _buildChannelTile(context, connector, channel);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showAddChannelDialog(context),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChannelTile(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
Channel channel,
|
||||
) {
|
||||
return Card(
|
||||
key: ValueKey('channel_${channel.index}'),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: channel.isPublicChannel
|
||||
? Colors.green.withValues(alpha: 0.2)
|
||||
: Colors.blue.withValues(alpha: 0.2),
|
||||
child: Icon(
|
||||
channel.isPublicChannel
|
||||
? Icons.public
|
||||
: channel.name.startsWith('#')
|
||||
? Icons.tag
|
||||
: Icons.lock,
|
||||
color: channel.isPublicChannel ? Colors.green : Colors.blue,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(
|
||||
channel.name.startsWith('#')
|
||||
? 'Hashtag channel'
|
||||
: channel.isPublicChannel
|
||||
? 'Public channel'
|
||||
: 'Private channel',
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
onPressed: () => _showEditChannelDialog(context, connector, channel),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
if (value == 'delete') {
|
||||
_confirmDeleteChannel(context, connector, channel);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChannelChatScreen(channel: channel),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddChannelDialog(BuildContext context) {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final nameController = TextEditingController();
|
||||
final pskController = TextEditingController();
|
||||
final maxChannels = connector.maxChannels;
|
||||
int selectedIndex = _findNextAvailableIndex(connector.channels, maxChannels);
|
||||
bool usePublicPsk = false;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialogState) => AlertDialog(
|
||||
title: const Text('Add Channel'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: selectedIndex,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Channel Index',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: List.generate(maxChannels, (i) => i)
|
||||
.map((i) => DropdownMenuItem(
|
||||
value: i,
|
||||
child: Text('Channel $i'),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setDialogState(() => selectedIndex = value);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Channel Name',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLength: 31,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CheckboxListTile(
|
||||
title: const Text('Use Public Channel'),
|
||||
subtitle: const Text('Standard public PSK'),
|
||||
value: usePublicPsk,
|
||||
onChanged: (value) {
|
||||
setDialogState(() {
|
||||
usePublicPsk = value ?? false;
|
||||
if (usePublicPsk) {
|
||||
nameController.text = 'Public';
|
||||
pskController.text = Channel.publicChannelPsk;
|
||||
} else {
|
||||
pskController.clear();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!usePublicPsk) ...[
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: pskController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'PSK (Base64)',
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.casino),
|
||||
tooltip: 'Generate random PSK',
|
||||
onPressed: () {
|
||||
final random = Random.secure();
|
||||
final bytes = Uint8List(16);
|
||||
for (int i = 0; i < 16; i++) {
|
||||
bytes[i] = random.nextInt(256);
|
||||
}
|
||||
pskController.text = base64Encode(bytes);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final name = nameController.text.trim();
|
||||
final pskBase64 = usePublicPsk
|
||||
? Channel.publicChannelPsk
|
||||
: pskController.text.trim();
|
||||
|
||||
if (name.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please enter a channel name')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Uint8List psk;
|
||||
try {
|
||||
final decoded = base64Decode(pskBase64);
|
||||
psk = Uint8List(16);
|
||||
for (int i = 0; i < decoded.length && i < 16; i++) {
|
||||
psk[i] = decoded[i];
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Invalid PSK format')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.pop(context);
|
||||
connector.setChannel(selectedIndex, name, psk);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Channel "$name" added')),
|
||||
);
|
||||
},
|
||||
child: const Text('Add'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditChannelDialog(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
Channel channel,
|
||||
) {
|
||||
final nameController = TextEditingController(text: channel.name);
|
||||
final pskController = TextEditingController(text: channel.pskBase64);
|
||||
bool smazEnabled = connector.isChannelSmazEnabled(channel.index);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setState) => AlertDialog(
|
||||
title: Text('Edit Channel ${channel.index}'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Channel Name',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLength: 31,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: pskController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'PSK (Base64)',
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.casino),
|
||||
tooltip: 'Generate random PSK',
|
||||
onPressed: () {
|
||||
final random = Random.secure();
|
||||
final bytes = Uint8List(16);
|
||||
for (int i = 0; i < 16; i++) {
|
||||
bytes[i] = random.nextInt(256);
|
||||
}
|
||||
pskController.text = base64Encode(bytes);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('SMAZ compression'),
|
||||
value: smazEnabled,
|
||||
onChanged: (value) => setState(() => smazEnabled = value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final name = nameController.text.trim();
|
||||
final pskBase64 = pskController.text.trim();
|
||||
|
||||
Uint8List psk;
|
||||
try {
|
||||
final decoded = base64Decode(pskBase64);
|
||||
psk = Uint8List(16);
|
||||
for (int i = 0; i < decoded.length && i < 16; i++) {
|
||||
psk[i] = decoded[i];
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Invalid PSK format')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.pop(context);
|
||||
connector.setChannel(channel.index, name, psk);
|
||||
connector.setChannelSmazEnabled(channel.index, smazEnabled);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Channel "$name" updated')),
|
||||
);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDeleteChannel(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
Channel channel,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Channel'),
|
||||
content: Text('Delete "${channel.name}"? This cannot be undone.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
connector.deleteChannel(channel.index);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Channel "${channel.name}" deleted')),
|
||||
);
|
||||
},
|
||||
child: const Text('Delete', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _addPublicChannel(BuildContext context, MeshCoreConnector connector) {
|
||||
final psk = Uint8List(16);
|
||||
final decoded = base64Decode(Channel.publicChannelPsk);
|
||||
for (int i = 0; i < decoded.length && i < 16; i++) {
|
||||
psk[i] = decoded[i];
|
||||
}
|
||||
connector.setChannel(0, 'Public', psk);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Public channel added')),
|
||||
);
|
||||
}
|
||||
|
||||
int _findNextAvailableIndex(List<Channel> channels, int maxChannels) {
|
||||
final usedIndices = channels.map((c) => c.index).toSet();
|
||||
for (int i = 0; i < maxChannels; i++) {
|
||||
if (!usedIndices.contains(i)) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,791 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/contact_group.dart';
|
||||
import '../storage/contact_group_store.dart';
|
||||
import '../widgets/repeater_login_dialog.dart';
|
||||
import '../utils/emoji_utils.dart';
|
||||
import 'chat_screen.dart';
|
||||
import 'repeater_hub_screen.dart';
|
||||
|
||||
enum ContactSortOption {
|
||||
lastSeen,
|
||||
recentMessages,
|
||||
name,
|
||||
type,
|
||||
}
|
||||
|
||||
class ContactsScreen extends StatefulWidget {
|
||||
const ContactsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ContactsScreen> createState() => _ContactsScreenState();
|
||||
}
|
||||
|
||||
class _ContactsScreenState extends State<ContactsScreen> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
ContactSortOption _sortOption = ContactSortOption.lastSeen;
|
||||
final ContactGroupStore _groupStore = ContactGroupStore();
|
||||
List<ContactGroup> _groups = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadGroups();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadGroups() async {
|
||||
final groups = await _groupStore.loadGroups();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_groups = groups;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveGroups() async {
|
||||
await _groupStore.saveGroups(_groups);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Contacts'),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
PopupMenuButton<ContactSortOption>(
|
||||
icon: const Icon(Icons.sort),
|
||||
tooltip: 'Sort by',
|
||||
onSelected: (option) {
|
||||
setState(() {
|
||||
_sortOption = option;
|
||||
});
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: ContactSortOption.lastSeen,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 20,
|
||||
color: _sortOption == ContactSortOption.lastSeen
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Last Seen',
|
||||
style: TextStyle(
|
||||
fontWeight: _sortOption == ContactSortOption.lastSeen
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: ContactSortOption.recentMessages,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble,
|
||||
size: 20,
|
||||
color: _sortOption == ContactSortOption.recentMessages
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Recent Messages',
|
||||
style: TextStyle(
|
||||
fontWeight: _sortOption == ContactSortOption.recentMessages
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: ContactSortOption.name,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.sort_by_alpha,
|
||||
size: 20,
|
||||
color: _sortOption == ContactSortOption.name
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Name',
|
||||
style: TextStyle(
|
||||
fontWeight: _sortOption == ContactSortOption.name
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: ContactSortOption.type,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.category,
|
||||
size: 20,
|
||||
color: _sortOption == ContactSortOption.type
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Type',
|
||||
style: TextStyle(
|
||||
fontWeight: _sortOption == ContactSortOption.type
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.group_add),
|
||||
tooltip: 'New group',
|
||||
onPressed: () {
|
||||
final contacts = context.read<MeshCoreConnector>().contacts;
|
||||
_showGroupEditor(context, contacts);
|
||||
},
|
||||
),
|
||||
Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, child) {
|
||||
return IconButton(
|
||||
icon: connector.isLoadingContacts
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
onPressed: connector.isLoadingContacts
|
||||
? null
|
||||
: () => connector.getContacts(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, child) {
|
||||
final contacts = connector.contacts;
|
||||
|
||||
if (contacts.isEmpty && connector.isLoadingContacts && _groups.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (contacts.isEmpty && _groups.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.people_outline, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No contacts yet',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Contacts will appear when devices advertise',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final filteredAndSorted = _filterAndSortContacts(contacts, connector);
|
||||
final filteredGroups = _filterAndSortGroups(_groups, contacts);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search contacts...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value.toLowerCase();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: filteredAndSorted.isEmpty && filteredGroups.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No contacts or groups found',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: () => connector.getContacts(),
|
||||
child: ListView.builder(
|
||||
itemCount: filteredGroups.length + filteredAndSorted.length,
|
||||
itemBuilder: (context, index) {
|
||||
if (index < filteredGroups.length) {
|
||||
final group = filteredGroups[index];
|
||||
return _buildGroupTile(context, group, contacts);
|
||||
}
|
||||
final contact = filteredAndSorted[index - filteredGroups.length];
|
||||
return _ContactTile(
|
||||
contact: contact,
|
||||
onTap: () => _openChat(context, contact),
|
||||
onLongPress: () => _showContactOptions(context, connector, contact),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<ContactGroup> _filterAndSortGroups(List<ContactGroup> groups, List<Contact> contacts) {
|
||||
final query = _searchQuery.trim().toLowerCase();
|
||||
final contactNames = <String, String>{};
|
||||
for (final contact in contacts) {
|
||||
contactNames[contact.publicKeyHex] = contact.name.toLowerCase();
|
||||
}
|
||||
|
||||
final filtered = groups.where((group) {
|
||||
if (query.isEmpty) return true;
|
||||
if (group.name.toLowerCase().contains(query)) return true;
|
||||
for (final key in group.memberKeys) {
|
||||
final name = contactNames[key];
|
||||
if (name != null && name.contains(query)) return true;
|
||||
}
|
||||
return false;
|
||||
}).toList();
|
||||
|
||||
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
return filtered;
|
||||
}
|
||||
|
||||
List<Contact> _filterAndSortContacts(List<Contact> contacts, MeshCoreConnector connector) {
|
||||
var filtered = contacts.where((contact) {
|
||||
if (_searchQuery.isEmpty) return true;
|
||||
return contact.name.toLowerCase().contains(_searchQuery);
|
||||
}).toList();
|
||||
|
||||
switch (_sortOption) {
|
||||
case ContactSortOption.lastSeen:
|
||||
filtered.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
|
||||
break;
|
||||
case ContactSortOption.recentMessages:
|
||||
filtered.sort((a, b) {
|
||||
final aMessages = connector.getMessages(a);
|
||||
final bMessages = connector.getMessages(b);
|
||||
final aLastMsg = aMessages.isEmpty ? DateTime(1970) : aMessages.last.timestamp;
|
||||
final bLastMsg = bMessages.isEmpty ? DateTime(1970) : bMessages.last.timestamp;
|
||||
return bLastMsg.compareTo(aLastMsg);
|
||||
});
|
||||
break;
|
||||
case ContactSortOption.name:
|
||||
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
break;
|
||||
case ContactSortOption.type:
|
||||
filtered.sort((a, b) {
|
||||
final typeCompare = a.type.compareTo(b.type);
|
||||
if (typeCompare != 0) return typeCompare;
|
||||
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
Widget _buildGroupTile(BuildContext context, ContactGroup group, List<Contact> contacts) {
|
||||
final memberContacts = _resolveGroupContacts(group, contacts);
|
||||
final subtitle = _formatGroupMembers(memberContacts);
|
||||
return ListTile(
|
||||
leading: const CircleAvatar(
|
||||
backgroundColor: Colors.teal,
|
||||
child: Icon(Icons.group, color: Colors.white, size: 20),
|
||||
),
|
||||
title: Text(group.name),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: Text(
|
||||
memberContacts.length.toString(),
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
onTap: () => _showGroupOptions(context, group, contacts),
|
||||
onLongPress: () => _showGroupOptions(context, group, contacts),
|
||||
);
|
||||
}
|
||||
|
||||
List<Contact> _resolveGroupContacts(ContactGroup group, List<Contact> contacts) {
|
||||
final byKey = <String, Contact>{};
|
||||
for (final contact in contacts) {
|
||||
byKey[contact.publicKeyHex] = contact;
|
||||
}
|
||||
final resolved = <Contact>[];
|
||||
for (final key in group.memberKeys) {
|
||||
final contact = byKey[key];
|
||||
if (contact != null) {
|
||||
resolved.add(contact);
|
||||
}
|
||||
}
|
||||
resolved.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
return resolved;
|
||||
}
|
||||
|
||||
String _formatGroupMembers(List<Contact> members) {
|
||||
if (members.isEmpty) return 'No members';
|
||||
final names = members.map((c) => c.name).toList();
|
||||
if (names.length <= 2) return names.join(', ');
|
||||
return '${names.take(2).join(', ')} +${names.length - 2}';
|
||||
}
|
||||
|
||||
void _openChat(BuildContext context, Contact contact) {
|
||||
// Check if this is a repeater
|
||||
if (contact.type == advTypeRepeater) {
|
||||
_showRepeaterLogin(context, contact);
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showRepeaterLogin(BuildContext context, Contact repeater) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => RepeaterLoginDialog(
|
||||
repeater: repeater,
|
||||
onLogin: (password) {
|
||||
// Navigate to repeater hub screen after successful login
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RepeaterHubScreen(
|
||||
repeater: repeater,
|
||||
password: password,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showGroupOptions(BuildContext context, ContactGroup group, List<Contact> contacts) {
|
||||
final members = _resolveGroupContacts(group, contacts);
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit),
|
||||
title: const Text('Edit Group'),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showGroupEditor(context, contacts, group: group);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete, color: Colors.red),
|
||||
title: const Text('Delete Group', style: TextStyle(color: Colors.red)),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_confirmDeleteGroup(context, group);
|
||||
},
|
||||
),
|
||||
if (members.isNotEmpty) const Divider(),
|
||||
...members.map((member) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.person),
|
||||
title: Text(member.name),
|
||||
subtitle: Text(member.typeLabel),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_openChat(context, member);
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDeleteGroup(BuildContext context, ContactGroup group) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete Group'),
|
||||
content: Text('Remove "${group.name}"?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(dialogContext);
|
||||
setState(() {
|
||||
_groups.removeWhere((g) => g.name == group.name);
|
||||
});
|
||||
await _saveGroups();
|
||||
},
|
||||
child: const Text('Delete', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showGroupEditor(
|
||||
BuildContext context,
|
||||
List<Contact> contacts, {
|
||||
ContactGroup? group,
|
||||
}) {
|
||||
final isEditing = group != null;
|
||||
final nameController = TextEditingController(text: group?.name ?? '');
|
||||
final selectedKeys = <String>{...group?.memberKeys ?? []};
|
||||
String filterQuery = '';
|
||||
final sortedContacts = List<Contact>.from(contacts)
|
||||
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (builderContext, setDialogState) {
|
||||
final filteredContacts = filterQuery.isEmpty
|
||||
? sortedContacts
|
||||
: sortedContacts
|
||||
.where((contact) => contact.name.toLowerCase().contains(filterQuery))
|
||||
.toList();
|
||||
return AlertDialog(
|
||||
title: Text(isEditing ? 'Edit Group' : 'New Group'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Group name',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Filter contacts...',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (value) {
|
||||
setDialogState(() {
|
||||
filterQuery = value.toLowerCase();
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: filteredContacts.isEmpty
|
||||
? const Center(child: Text('No contacts match your filter'))
|
||||
: ListView.builder(
|
||||
itemCount: filteredContacts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final contact = filteredContacts[index];
|
||||
final isSelected = selectedKeys.contains(contact.publicKeyHex);
|
||||
return CheckboxListTile(
|
||||
value: isSelected,
|
||||
title: Text(contact.name),
|
||||
subtitle: Text(contact.typeLabel),
|
||||
onChanged: (value) {
|
||||
setDialogState(() {
|
||||
if (value == true) {
|
||||
selectedKeys.add(contact.publicKeyHex);
|
||||
} else {
|
||||
selectedKeys.remove(contact.publicKeyHex);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final name = nameController.text.trim();
|
||||
if (name.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Group name is required')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final exists = _groups.any((g) {
|
||||
if (isEditing && g.name == group!.name) return false;
|
||||
return g.name.toLowerCase() == name.toLowerCase();
|
||||
});
|
||||
if (exists) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Group "$name" already exists')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
if (isEditing) {
|
||||
final index = _groups.indexWhere((g) => g.name == group!.name);
|
||||
if (index != -1) {
|
||||
_groups[index] = ContactGroup(
|
||||
name: name,
|
||||
memberKeys: selectedKeys.toList(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
_groups.add(ContactGroup(name: name, memberKeys: selectedKeys.toList()));
|
||||
}
|
||||
});
|
||||
await _saveGroups();
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.pop(dialogContext);
|
||||
}
|
||||
},
|
||||
child: Text(isEditing ? 'Save' : 'Create'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showContactOptions(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
Contact contact,
|
||||
) {
|
||||
final isRepeater = contact.type == advTypeRepeater;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isRepeater)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cell_tower, color: Colors.orange),
|
||||
title: const Text('Manage Repeater'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showRepeaterLogin(context, contact);
|
||||
},
|
||||
)
|
||||
else
|
||||
ListTile(
|
||||
leading: const Icon(Icons.chat),
|
||||
title: const Text('Open Chat'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_openChat(context, contact);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete, color: Colors.red),
|
||||
title: const Text('Delete Contact', style: TextStyle(color: Colors.red)),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_confirmDelete(context, connector, contact);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDelete(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
Contact contact,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Contact'),
|
||||
content: Text('Remove ${contact.name} from contacts?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
connector.removeContact(contact);
|
||||
},
|
||||
child: const Text('Delete', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ContactTile extends StatelessWidget {
|
||||
final Contact contact;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLongPress;
|
||||
|
||||
const _ContactTile({
|
||||
required this.contact,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: _getTypeColor(contact.type),
|
||||
child: _buildContactAvatar(contact),
|
||||
),
|
||||
title: Text(contact.name),
|
||||
subtitle: Text('${contact.typeLabel} • ${contact.pathLabel}'),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_formatLastSeen(contact.lastSeen),
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
if (contact.hasLocation)
|
||||
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
|
||||
],
|
||||
),
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContactAvatar(Contact contact) {
|
||||
final emoji = firstEmoji(contact.name);
|
||||
if (emoji != null) {
|
||||
return Text(
|
||||
emoji,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
);
|
||||
}
|
||||
return Icon(_getTypeIcon(contact.type), color: Colors.white, size: 20);
|
||||
}
|
||||
|
||||
IconData _getTypeIcon(int type) {
|
||||
switch (type) {
|
||||
case advTypeChat:
|
||||
return Icons.chat;
|
||||
case advTypeRepeater:
|
||||
return Icons.cell_tower;
|
||||
case advTypeRoom:
|
||||
return Icons.group;
|
||||
case advTypeSensor:
|
||||
return Icons.sensors;
|
||||
default:
|
||||
return Icons.device_unknown;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getTypeColor(int type) {
|
||||
switch (type) {
|
||||
case advTypeChat:
|
||||
return Colors.blue;
|
||||
case advTypeRepeater:
|
||||
return Colors.orange;
|
||||
case advTypeRoom:
|
||||
return Colors.purple;
|
||||
case advTypeSensor:
|
||||
return Colors.green;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatLastSeen(DateTime lastSeen) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(lastSeen);
|
||||
|
||||
if (diff.inMinutes < 1) return 'Just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
if (diff.inDays < 7) return '${diff.inDays}d ago';
|
||||
return '${lastSeen.month}/${lastSeen.day}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import 'channels_screen.dart';
|
||||
import 'contacts_screen.dart';
|
||||
import 'map_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
/// Main hub screen after connecting to a MeshCore device
|
||||
class DeviceScreen extends StatefulWidget {
|
||||
const DeviceScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DeviceScreen> createState() => _DeviceScreenState();
|
||||
}
|
||||
|
||||
class _DeviceScreenState extends State<DeviceScreen> {
|
||||
bool _showBatteryVoltage = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, child) {
|
||||
// If disconnected, pop back to scanner
|
||||
if (!connector.isConnected) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) {
|
||||
Navigator.popUntil(context, (route) => route.isFirst);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(connector.device?.platformName ?? 'MeshCore Device'),
|
||||
centerTitle: true,
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bluetooth_disabled),
|
||||
tooltip: 'Disconnect',
|
||||
onPressed: () => _disconnect(context, connector),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Connection status card
|
||||
_buildStatusCard(connector, context),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Navigation grid
|
||||
Expanded(
|
||||
child: _buildNavigationGrid(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusCard(MeshCoreConnector connector, BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.bluetooth_connected, color: Colors.green, size: 32),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
connector.device?.platformName ?? 'Unknown Device',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
connector.device?.remoteId.toString() ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Text(
|
||||
'Connected',
|
||||
style: TextStyle(
|
||||
color: Colors.green,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildBatteryIndicator(connector, context),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBatteryIndicator(MeshCoreConnector connector, BuildContext context) {
|
||||
final percent = connector.batteryPercent;
|
||||
final millivolts = connector.batteryMillivolts;
|
||||
final percentLabel = percent != null ? '$percent%' : '--%';
|
||||
final voltageLabel = millivolts == null
|
||||
? '-- V'
|
||||
: '${(millivolts / 1000.0).toStringAsFixed(2)} V';
|
||||
final displayLabel = _showBatteryVoltage ? voltageLabel : percentLabel;
|
||||
final icon = _batteryIcon(percent);
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_showBatteryVoltage = !_showBatteryVoltage;
|
||||
});
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: Colors.grey[700]),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
displayLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[700],
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _batteryIcon(int? percent) {
|
||||
if (percent == null) return Icons.battery_unknown;
|
||||
if (percent <= 15) return Icons.battery_alert;
|
||||
return Icons.battery_full;
|
||||
}
|
||||
|
||||
Widget _buildNavigationGrid(BuildContext context) {
|
||||
final items = [
|
||||
_NavItem(
|
||||
icon: Icons.people_outline,
|
||||
label: 'Contacts',
|
||||
color: Colors.blue,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const ContactsScreen()),
|
||||
),
|
||||
),
|
||||
_NavItem(
|
||||
icon: Icons.tag,
|
||||
label: 'Channels',
|
||||
color: Colors.green,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const ChannelsScreen()),
|
||||
),
|
||||
),
|
||||
_NavItem(
|
||||
icon: Icons.map_outlined,
|
||||
label: 'Map',
|
||||
color: Colors.orange,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const MapScreen()),
|
||||
),
|
||||
),
|
||||
_NavItem(
|
||||
icon: Icons.settings_outlined,
|
||||
label: 'Settings',
|
||||
color: Colors.grey,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return GridView.builder(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
childAspectRatio: 1.2,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return _buildNavCard(item);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavCard(_NavItem item) {
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: item.onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
item.icon,
|
||||
size: 48,
|
||||
color: item.color,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
item.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _disconnect(BuildContext context, MeshCoreConnector connector) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Disconnect'),
|
||||
content: const Text('Are you sure you want to disconnect from this device?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Disconnect'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await connector.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _NavItem {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
_NavItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,528 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../widgets/debug_frame_viewer.dart';
|
||||
import '../services/repeater_command_service.dart';
|
||||
|
||||
class RepeaterCliScreen extends StatefulWidget {
|
||||
final Contact repeater;
|
||||
final String password;
|
||||
|
||||
const RepeaterCliScreen({
|
||||
super.key,
|
||||
required this.repeater,
|
||||
required this.password,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RepeaterCliScreen> createState() => _RepeaterCliScreenState();
|
||||
}
|
||||
|
||||
class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
final TextEditingController _commandController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final List<Map<String, String>> _commandHistory = [];
|
||||
int _historyIndex = -1;
|
||||
StreamSubscription<Uint8List>? _frameSubscription;
|
||||
RepeaterCommandService? _commandService;
|
||||
|
||||
// Common commands for quick access
|
||||
final List<Map<String, String>> _quickCommands = [
|
||||
{'label': 'Get Name', 'command': 'get name'},
|
||||
{'label': 'Get Radio', 'command': 'get radio'},
|
||||
{'label': 'Get TX', 'command': 'get tx'},
|
||||
{'label': 'Neighbors', 'command': 'neighbors'},
|
||||
{'label': 'Version', 'command': 'ver'},
|
||||
{'label': 'Advertise', 'command': 'advert'},
|
||||
{'label': 'Clock', 'command': 'clock'},
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
_commandService = RepeaterCommandService(connector);
|
||||
_setupMessageListener();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_frameSubscription?.cancel();
|
||||
_commandService?.dispose();
|
||||
_commandController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _setupMessageListener() {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
|
||||
// Listen for incoming text messages from the repeater
|
||||
_frameSubscription = connector.receivedFrames.listen((frame) {
|
||||
if (frame.isEmpty) return;
|
||||
|
||||
// Check if it's a text message response
|
||||
if (frame[0] == respCodeContactMsgRecv ||
|
||||
frame[0] == respCodeContactMsgRecvV3) {
|
||||
_handleTextMessageResponse(frame);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleTextMessageResponse(Uint8List frame) {
|
||||
final parsed = parseContactMessageText(frame);
|
||||
if (parsed == null) return;
|
||||
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
|
||||
|
||||
// Notify command service of response (for retry handling)
|
||||
_commandService?.handleResponse(widget.repeater, parsed.text);
|
||||
|
||||
// Note: The command service will handle the response via the Future
|
||||
// We don't need to add it to history here anymore as _sendCommand will do it
|
||||
}
|
||||
|
||||
bool _matchesRepeaterPrefix(Uint8List prefix) {
|
||||
final target = widget.repeater.publicKey;
|
||||
if (target.length < 6 || prefix.length < 6) return false;
|
||||
for (int i = 0; i < 6; i++) {
|
||||
if (prefix[i] != target[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void _sendCommand({bool showDebug = false}) async {
|
||||
final command = _commandController.text.trim();
|
||||
if (command.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_commandHistory.add({
|
||||
'type': 'command',
|
||||
'text': command,
|
||||
'timestamp': DateTime.now().toString(),
|
||||
});
|
||||
});
|
||||
|
||||
// Show debug info if requested
|
||||
if (showDebug && mounted) {
|
||||
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command);
|
||||
DebugFrameViewer.showFrameDebug(context, frame, 'CLI Command Frame');
|
||||
}
|
||||
|
||||
// Send CLI command to repeater with retry
|
||||
try {
|
||||
if (_commandService != null) {
|
||||
final response = await _commandService!.sendCommand(
|
||||
widget.repeater,
|
||||
command,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_commandHistory.add({
|
||||
'type': 'response',
|
||||
'text': response,
|
||||
'timestamp': DateTime.now().toString(),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_commandHistory.add({
|
||||
'type': 'response',
|
||||
'text': 'Error: $e',
|
||||
'timestamp': DateTime.now().toString(),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_commandController.clear();
|
||||
_historyIndex = -1;
|
||||
|
||||
// Auto-scroll to bottom
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _useQuickCommand(String command) {
|
||||
_commandController.text = command;
|
||||
_sendCommand();
|
||||
}
|
||||
|
||||
void _navigateHistory(bool up) {
|
||||
final commands = _commandHistory
|
||||
.where((entry) => entry['type'] == 'command')
|
||||
.toList()
|
||||
.reversed
|
||||
.toList();
|
||||
|
||||
if (commands.isEmpty) return;
|
||||
|
||||
if (up) {
|
||||
if (_historyIndex < commands.length - 1) {
|
||||
_historyIndex++;
|
||||
}
|
||||
} else {
|
||||
if (_historyIndex > 0) {
|
||||
_historyIndex--;
|
||||
} else {
|
||||
_historyIndex = -1;
|
||||
_commandController.clear();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_historyIndex >= 0 && _historyIndex < commands.length) {
|
||||
_commandController.text = commands[_historyIndex]['text'] ?? '';
|
||||
_commandController.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: _commandController.text.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _clearHistory() {
|
||||
setState(() {
|
||||
_commandHistory.clear();
|
||||
_historyIndex = -1;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Repeater CLI'),
|
||||
Text(
|
||||
widget.repeater.name,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bug_report),
|
||||
tooltip: 'Debug Next Command',
|
||||
onPressed: () {
|
||||
// Set a flag or just send next command with debug
|
||||
if (_commandController.text.trim().isNotEmpty) {
|
||||
_sendCommand(showDebug: true);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Enter a command first')),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.help_outline),
|
||||
tooltip: 'Command Help',
|
||||
onPressed: () => _showCommandHelp(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear_all),
|
||||
tooltip: 'Clear History',
|
||||
onPressed: _commandHistory.isEmpty ? null : _clearHistory,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildQuickCommandsBar(),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: _commandHistory.isEmpty
|
||||
? _buildEmptyState()
|
||||
: _buildCommandHistory(),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
_buildCommandInput(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuickCommandsBar() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: _quickCommands.map((cmd) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ActionChip(
|
||||
label: Text(cmd['label']!),
|
||||
onPressed: () => _useQuickCommand(cmd['command']!),
|
||||
avatar: const Icon(Icons.play_arrow, size: 16),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.terminal, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No commands sent yet',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Type a command below or use quick commands',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCommandHistory() {
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _commandHistory.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = _commandHistory[index];
|
||||
final isCommand = entry['type'] == 'command';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: isCommand
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Icon(
|
||||
isCommand ? Icons.chevron_right : Icons.arrow_back,
|
||||
size: 16,
|
||||
color: isCommand
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||
: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SelectableText(
|
||||
entry['text']!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
color: isCommand
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCommandInput() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: SafeArea(
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_upward, size: 20),
|
||||
tooltip: 'Previous command',
|
||||
onPressed: () => _navigateHistory(true),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_downward, size: 20),
|
||||
tooltip: 'Next command',
|
||||
onPressed: () => _navigateHistory(false),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _commandController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Enter command...',
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
prefixText: '> ',
|
||||
),
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendCommand(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filled(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: _sendCommand,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showCommandHelp(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Commands List'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'NOTE: for the various "set ..." commands, there is also a "get ..." command.',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection('General', [
|
||||
'advert - Sends an advertisement packet',
|
||||
"reboot - Reboots the device. (note, you'll prob get 'Timeout' which is normal)",
|
||||
"clock - Displays current time per device's clock.",
|
||||
'password {new-password} - Sets a new admin password for the device.',
|
||||
'ver - Shows the device version and firmware build date.',
|
||||
'clear stats - Resets various stats counters to zero.',
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection('Settings', [
|
||||
'set af {air-time-factor} - Sets the air-time-factor.',
|
||||
'set tx {tx-power-dbm} - Sets LoRa transmit power in dBm. (reboot to apply)',
|
||||
'set repeat {on|off} - Enables or disables the repeater role for this node.',
|
||||
"set allow.read.only {on|off} - (Room server) If 'on', then login in blank password will be allowed, but cannot Post to room. (just read only)",
|
||||
'set flood.max {max-hops} - Sets the maximum number of hops of inbound flood packet (if >= max, packet is not forwarded)',
|
||||
'set int.thresh {db} - Sets the Interference Threshold (in DB). Default is 14. Set to 0 to disable channel interference detection.',
|
||||
'set agc.reset.interval {seconds} - Sets the interval to reset the Auto Gain Controller. Set to 0 to disable.',
|
||||
"set multi.acks {0|1} - Enables or disables the 'double ACKs' feature.",
|
||||
'set advert.interval {minutes} - Sets the timer interval in minutes to send a local (zero-hop) advertisement packet. Set to 0 to disable.',
|
||||
'set flood.advert.interval {hours} - Sets the timer interval in hours to send a flood advertisement packet. Set to 0 to disable.',
|
||||
'set guest.password {guess-password} - Sets/updates the guest password. (for repeaters, guest logins can send the "Get Stats" request)',
|
||||
'set name {name} - Sets the advertisement name.',
|
||||
'set lat {latitude} - Sets the advertisement map latitude. (decimal degrees)',
|
||||
'set lon {longitude} - Sets the advertisement map longitude. (decimal degrees)',
|
||||
'set radio {freq},{bw},{sf},{cr} - Sets completely new radio params, and saves to preferences. Requires a "reboot" command to apply.',
|
||||
'set rxdelay {base} - Sets (experimental) base (must be > 1 for effect) for applying slight delay to received packets, based on signal strength/score. Set to 0 to disable.',
|
||||
'set txdelay {factor} - Sets a factor multiplied with time-on-air for a flood-mode packet and with a randomized slot system, to delay its forwarding. (to decrease likelihood of collisions)',
|
||||
'set direct.txdelay {factor} - Same as txdelay, but for applying a random delay to the forwarding of direct-mode packets.',
|
||||
'set bridge.enabled {on|off} - Enable/Disable bridge.',
|
||||
'set bridge.delay {0-10000} - Set delay before retransmitting packets.',
|
||||
'set bridge.source {rx|tx} - Choose wether the bridge will retransmit received packets or transmitted packets.',
|
||||
'set bridge.baud {speed} - Set serial link baudrate for rs232 bridges.',
|
||||
'set bridge.secret {shared-secret} - Set bridge secret for espnow bridges.',
|
||||
'set adc.multiplier {factor} - Sets custom factor to adjust reported battery voltage (only supported on select boards).',
|
||||
'tempradio {freq},{bw},{sf},{cr},{minutes} - Sets temporary radio params for the given number of {minutes}, reverting to original radio params afterward. (does NOT save to preferences).',
|
||||
'setperm {pubkey-hex} {permissions} - Modifies the ACL. Removes matching entry (by pubkey prefix) if "permissions" is zero. Adds new entry if pubkey-hex is full length and is not currently in ACL. Updates entry by matching pubkey prefix. Permission bits vary per firmware role, but low 2 bits are: 0 (Guest), 1 (Read only), 2 (Read write), 3 (Admin)',
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection('Bridge', [
|
||||
'get bridge.type - Gets bridge type none, rs232, espnow',
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection('Logging', [
|
||||
'log start - Starts packet logging to file system.',
|
||||
'log stop - Stops packet logging to file system.',
|
||||
'log erase - Erases the packet logs from file system.',
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection('Neighbors (Repeater only)', [
|
||||
'neighbors - Shows a list of other repeater nodes heard via zero-hop adverts. Each line is {id-prefix-hex}:{timestamp}:{snr-times-4}',
|
||||
'neighbor.remove {pubkey-prefix} - Removes first matching entry (by pubkey prefix (hex)), from neighbors list.',
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection('Region Management (Repeater only)', [
|
||||
'region commands have been introduced to manage region definitions and permissions.',
|
||||
'region - (serial only) Lists all defined regions and current flood permissions.',
|
||||
'region load - NOTE: this is a special multi-command invocation. Each subsequent command is a region name (indented with spaces to indicate parent hierarchy, with one space at minimum). Terminated by sending a blank line/command.',
|
||||
"region get {* | name-prefix} - Searches for region with given name prefix (or '*' for the global scope). Replies with \"-> {region-name} ({parent-name}) {'F'}\"",
|
||||
'region put {name} {* | parent-name-prefix} - Adds or updates a region definition with given name.',
|
||||
'region remove {name} - Removes a region definition with given name. (must match exactly, and have no child regions)',
|
||||
"region allowf {* | name-prefix} - Sets the 'F'lood permission for the given region. ('*' for the global/legacy scope)",
|
||||
"region denyf {* | name-prefix} - Removes the 'F'lood permission for the given region. (NOTE: at this stage NOT advised to use this on the global/legacy scope!!)",
|
||||
"region home - Replies with the current 'home' region. (Note applied anywhere yet, reserved for future)",
|
||||
"region home {* | name-prefix} - Sets the 'home' region.",
|
||||
'region save - Persists the region list/map to storage.',
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection('GPS Management', [
|
||||
'gps command has been introduced to manage location related topics.',
|
||||
'gps - Gives status of gps. When gps is off, it replies only off, if on it replies with on, {status}, {fix}, {sat count}',
|
||||
'gps {on|off} - Toggles gps power state.',
|
||||
'gps sync - Syncs node time with gps clock.',
|
||||
"gps setloc - Sets node's position to gps coordinates and save preferences.",
|
||||
'gps advert - Gives location advert configuration of the node:',
|
||||
"none: don't include location in adverts",
|
||||
'share: share gps location (from SensorManager)',
|
||||
'prefs: advert the location stored in preferences',
|
||||
'gps advert {none|share|prefs} - Sets location advert configuration.',
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHelpSection(String title, List<String> commands) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...commands.map((cmd) => Padding(
|
||||
padding: const EdgeInsets.only(left: 8, bottom: 4),
|
||||
child: Text(
|
||||
'• $cmd',
|
||||
style: const TextStyle(fontSize: 13, fontFamily: 'monospace'),
|
||||
),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/contact.dart';
|
||||
import 'repeater_status_screen.dart';
|
||||
import 'repeater_cli_screen.dart';
|
||||
import 'repeater_settings_screen.dart';
|
||||
|
||||
class RepeaterHubScreen extends StatelessWidget {
|
||||
final Contact repeater;
|
||||
final String password;
|
||||
|
||||
const RepeaterHubScreen({
|
||||
super.key,
|
||||
required this.repeater,
|
||||
required this.password,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Repeater Management'),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
),
|
||||
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]),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildManagementCard(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required Color color,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 32),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, color: Colors.grey[400]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,508 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../services/repeater_command_service.dart';
|
||||
|
||||
class RepeaterStatusScreen extends StatefulWidget {
|
||||
final Contact repeater;
|
||||
final String password;
|
||||
|
||||
const RepeaterStatusScreen({
|
||||
super.key,
|
||||
required this.repeater,
|
||||
required this.password,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RepeaterStatusScreen> createState() => _RepeaterStatusScreenState();
|
||||
}
|
||||
|
||||
class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
bool _isLoading = false;
|
||||
StreamSubscription<Uint8List>? _frameSubscription;
|
||||
RepeaterCommandService? _commandService;
|
||||
Timer? _statusTimeout;
|
||||
DateTime? _statusRequestedAt;
|
||||
int? _batteryMv;
|
||||
int? _uptimeSecs;
|
||||
int? _queueLen;
|
||||
int? _debugFlags;
|
||||
int? _lastRssi;
|
||||
double? _lastSnr;
|
||||
int? _noiseFloor;
|
||||
int? _txAirSecs;
|
||||
int? _rxAirSecs;
|
||||
int? _packetsSent;
|
||||
int? _packetsRecv;
|
||||
int? _floodTx;
|
||||
int? _directTx;
|
||||
int? _floodRx;
|
||||
int? _directRx;
|
||||
int? _dupFlood;
|
||||
int? _dupDirect;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
_commandService = RepeaterCommandService(connector);
|
||||
_setupMessageListener();
|
||||
_loadStatus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_frameSubscription?.cancel();
|
||||
_commandService?.dispose();
|
||||
_statusTimeout?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _setupMessageListener() {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
|
||||
// Listen for incoming text messages from the repeater
|
||||
_frameSubscription = connector.receivedFrames.listen((frame) {
|
||||
if (frame.isEmpty) return;
|
||||
|
||||
// Check if it's a text message response
|
||||
if (frame[0] == pushCodeStatusResponse) {
|
||||
_handleStatusResponse(frame);
|
||||
} else if (frame[0] == respCodeContactMsgRecv ||
|
||||
frame[0] == respCodeContactMsgRecvV3) {
|
||||
_handleTextMessageResponse(frame);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleTextMessageResponse(Uint8List frame) {
|
||||
final parsed = parseContactMessageText(frame);
|
||||
if (parsed == null) return;
|
||||
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
|
||||
|
||||
// Notify command service of response (for retry handling)
|
||||
_commandService?.handleResponse(widget.repeater, parsed.text);
|
||||
|
||||
// Parse status responses
|
||||
_parseStatusResponse(parsed.text);
|
||||
}
|
||||
|
||||
void _handleStatusResponse(Uint8List frame) {
|
||||
if (frame.length < 8) return;
|
||||
final prefix = frame.sublist(2, 8);
|
||||
if (!_matchesRepeaterPrefix(prefix)) return;
|
||||
|
||||
const payloadOffset = 8;
|
||||
const statsSize = 52;
|
||||
if (frame.length < payloadOffset + statsSize) return;
|
||||
|
||||
final data = ByteData.sublistView(frame, payloadOffset, payloadOffset + statsSize);
|
||||
int offset = 0;
|
||||
|
||||
final batteryMv = data.getUint16(offset, Endian.little);
|
||||
offset += 2;
|
||||
final queueLen = data.getUint16(offset, Endian.little);
|
||||
offset += 2;
|
||||
final noiseFloor = data.getInt16(offset, Endian.little);
|
||||
offset += 2;
|
||||
final lastRssi = data.getInt16(offset, Endian.little);
|
||||
offset += 2;
|
||||
final packetsRecv = data.getUint32(offset, Endian.little);
|
||||
offset += 4;
|
||||
final packetsSent = data.getUint32(offset, Endian.little);
|
||||
offset += 4;
|
||||
final txAirSecs = data.getUint32(offset, Endian.little);
|
||||
offset += 4;
|
||||
final uptimeSecs = data.getUint32(offset, Endian.little);
|
||||
offset += 4;
|
||||
final floodTx = data.getUint32(offset, Endian.little);
|
||||
offset += 4;
|
||||
final directTx = data.getUint32(offset, Endian.little);
|
||||
offset += 4;
|
||||
final floodRx = data.getUint32(offset, Endian.little);
|
||||
offset += 4;
|
||||
final directRx = data.getUint32(offset, Endian.little);
|
||||
offset += 4;
|
||||
final errEvents = data.getUint16(offset, Endian.little);
|
||||
offset += 2;
|
||||
final lastSnrRaw = data.getInt16(offset, Endian.little);
|
||||
offset += 2;
|
||||
final directDups = data.getUint16(offset, Endian.little);
|
||||
offset += 2;
|
||||
final floodDups = data.getUint16(offset, Endian.little);
|
||||
offset += 2;
|
||||
final rxAirSecs = data.getUint32(offset, Endian.little);
|
||||
|
||||
_statusTimeout?.cancel();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_batteryMv = batteryMv;
|
||||
_queueLen = queueLen;
|
||||
_noiseFloor = noiseFloor;
|
||||
_lastRssi = lastRssi;
|
||||
_packetsRecv = packetsRecv;
|
||||
_packetsSent = packetsSent;
|
||||
_txAirSecs = txAirSecs;
|
||||
_rxAirSecs = rxAirSecs;
|
||||
_uptimeSecs = uptimeSecs;
|
||||
_floodTx = floodTx;
|
||||
_directTx = directTx;
|
||||
_floodRx = floodRx;
|
||||
_directRx = directRx;
|
||||
_debugFlags = errEvents;
|
||||
_lastSnr = lastSnrRaw / 4.0;
|
||||
_dupDirect = directDups;
|
||||
_dupFlood = floodDups;
|
||||
});
|
||||
}
|
||||
|
||||
bool _matchesRepeaterPrefix(Uint8List prefix) {
|
||||
final target = widget.repeater.publicKey;
|
||||
if (target.length < 6 || prefix.length < 6) return false;
|
||||
for (int i = 0; i < 6; i++) {
|
||||
if (prefix[i] != target[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void _parseStatusResponse(String response) {
|
||||
final trimmed = response.trim();
|
||||
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
||||
try {
|
||||
final data = jsonDecode(trimmed) as Map<String, dynamic>;
|
||||
if (data.containsKey('battery_mv')) {
|
||||
_batteryMv = _asInt(data['battery_mv']);
|
||||
_uptimeSecs = _asInt(data['uptime_secs']);
|
||||
_queueLen = _asInt(data['queue_len']);
|
||||
_debugFlags = _asInt(data['errors']);
|
||||
} else if (data.containsKey('noise_floor')) {
|
||||
_noiseFloor = _asInt(data['noise_floor']);
|
||||
_lastRssi = _asInt(data['last_rssi']);
|
||||
_lastSnr = _asDouble(data['last_snr']);
|
||||
_txAirSecs = _asInt(data['tx_air_secs']);
|
||||
_rxAirSecs = _asInt(data['rx_air_secs']);
|
||||
} else if (data.containsKey('recv') || data.containsKey('sent')) {
|
||||
_packetsRecv = _asInt(data['recv']);
|
||||
_packetsSent = _asInt(data['sent']);
|
||||
_floodTx = _asInt(data['flood_tx']);
|
||||
_directTx = _asInt(data['direct_tx']);
|
||||
_floodRx = _asInt(data['flood_rx']);
|
||||
_directRx = _asInt(data['direct_rx']);
|
||||
_dupFlood = _asInt(data['dup_flood']);
|
||||
_dupDirect = _asInt(data['dup_direct']);
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore parse failures for non-JSON responses.
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadStatus() async {
|
||||
if (_commandService == null) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_statusRequestedAt = DateTime.now();
|
||||
_batteryMv = null;
|
||||
_uptimeSecs = null;
|
||||
_queueLen = null;
|
||||
_debugFlags = null;
|
||||
_lastRssi = null;
|
||||
_lastSnr = null;
|
||||
_noiseFloor = null;
|
||||
_txAirSecs = null;
|
||||
_rxAirSecs = null;
|
||||
_packetsSent = null;
|
||||
_packetsRecv = null;
|
||||
_floodTx = null;
|
||||
_directTx = null;
|
||||
_floodRx = null;
|
||||
_directRx = null;
|
||||
_dupFlood = null;
|
||||
_dupDirect = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final frame = buildSendStatusRequestFrame(widget.repeater.publicKey);
|
||||
await connector.sendFrame(frame);
|
||||
|
||||
_statusTimeout?.cancel();
|
||||
_statusTimeout = Timer(const Duration(seconds: 12), () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Status request timed out.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error loading status: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Repeater Status'),
|
||||
Text(
|
||||
widget.repeater.name,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
onPressed: _isLoading ? null : _loadStatus,
|
||||
tooltip: 'Refresh',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _loadStatus,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildSystemInfoCard(),
|
||||
const SizedBox(height: 16),
|
||||
_buildRadioStatsCard(),
|
||||
const SizedBox(height: 16),
|
||||
_buildPacketStatsCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSystemInfoCard() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Theme.of(context).primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'System Information',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
_buildInfoRow('Battery', _batteryText()),
|
||||
_buildInfoRow('Clock (at login)', _clockText()),
|
||||
_buildInfoRow('Uptime', _formatDuration(_uptimeSecs)),
|
||||
_buildInfoRow('Queue Length', _formatValue(_queueLen)),
|
||||
_buildInfoRow('Debug Flags', _formatValue(_debugFlags)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRadioStatsCard() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.radio, color: Theme.of(context).primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Radio Statistics',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
_buildInfoRow('Last RSSI', _formatValue(_lastRssi, suffix: ' dB')),
|
||||
_buildInfoRow('Last SNR', _formatSnr(_lastSnr)),
|
||||
_buildInfoRow('Noise Floor', _formatValue(_noiseFloor, suffix: ' dB')),
|
||||
_buildInfoRow('TX Airtime', _formatDuration(_txAirSecs)),
|
||||
_buildInfoRow('RX Airtime', _formatDuration(_rxAirSecs)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPacketStatsCard() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.analytics, color: Theme.of(context).primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Packet Statistics',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
_buildInfoRow('Sent', _packetTxText()),
|
||||
_buildInfoRow('Received', _packetRxText()),
|
||||
_buildInfoRow('Duplicates', _duplicateText()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 130,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(fontWeight: FontWeight.w400),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int? _asInt(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is int) return value;
|
||||
if (value is double) return value.round();
|
||||
return int.tryParse(value.toString());
|
||||
}
|
||||
|
||||
double? _asDouble(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is double) return value;
|
||||
if (value is int) return value.toDouble();
|
||||
return double.tryParse(value.toString());
|
||||
}
|
||||
|
||||
String _batteryText() {
|
||||
if (_batteryMv == null) return '—';
|
||||
final percent = _batteryPercentFromMv(_batteryMv!);
|
||||
final volts = (_batteryMv! / 1000.0).toStringAsFixed(2);
|
||||
return '$percent% / ${volts}V';
|
||||
}
|
||||
|
||||
int _batteryPercentFromMv(int millivolts) {
|
||||
const minMv = 3000;
|
||||
const maxMv = 4200;
|
||||
if (millivolts <= minMv) return 0;
|
||||
if (millivolts >= maxMv) return 100;
|
||||
return (((millivolts - minMv) * 100) / (maxMv - minMv)).round();
|
||||
}
|
||||
|
||||
String _clockText() {
|
||||
if (_statusRequestedAt == null) return '—';
|
||||
final dt = _statusRequestedAt!;
|
||||
final date = '${dt.day}/${dt.month}/${dt.year}';
|
||||
final time = '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
return '$date $time';
|
||||
}
|
||||
|
||||
String _formatDuration(int? seconds) {
|
||||
if (seconds == null) return '—';
|
||||
final days = seconds ~/ 86400;
|
||||
final hours = (seconds % 86400) ~/ 3600;
|
||||
final minutes = (seconds % 3600) ~/ 60;
|
||||
final secs = seconds % 60;
|
||||
return '$days days ${hours}h ${minutes}m ${secs}s';
|
||||
}
|
||||
|
||||
String _packetTxText() {
|
||||
if (_packetsSent == null) return '—';
|
||||
final flood = _formatValue(_floodTx);
|
||||
final direct = _formatValue(_directTx);
|
||||
return 'Total: $_packetsSent, Flood: $flood, Direct: $direct';
|
||||
}
|
||||
|
||||
String _packetRxText() {
|
||||
if (_packetsRecv == null) return '—';
|
||||
final flood = _formatValue(_floodRx);
|
||||
final direct = _formatValue(_directRx);
|
||||
return 'Total: $_packetsRecv, Flood: $flood, Direct: $direct';
|
||||
}
|
||||
|
||||
String _duplicateText() {
|
||||
if (_dupFlood != null || _dupDirect != null) {
|
||||
final flood = _formatValue(_dupFlood);
|
||||
final direct = _formatValue(_dupDirect);
|
||||
return 'Flood: $flood, Direct: $direct';
|
||||
}
|
||||
if (_packetsRecv == null || _floodRx == null || _directRx == null) return '—';
|
||||
final dupTotal = _packetsRecv! - _floodRx! - _directRx!;
|
||||
if (dupTotal < 0) return '—';
|
||||
return 'Total: $dupTotal';
|
||||
}
|
||||
|
||||
String _formatValue(num? value, {String? suffix}) {
|
||||
if (value == null) return '—';
|
||||
return suffix == null ? value.toString() : '$value$suffix';
|
||||
}
|
||||
|
||||
String _formatSnr(double? snr) {
|
||||
if (snr == null) return '—';
|
||||
return snr.toStringAsFixed(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../widgets/device_tile.dart';
|
||||
import 'device_screen.dart';
|
||||
|
||||
/// Screen for scanning and connecting to MeshCore devices
|
||||
class ScannerScreen extends StatelessWidget {
|
||||
const ScannerScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('MeshCore Open'),
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, child) {
|
||||
final isScanning = connector.state == MeshCoreConnectionState.scanning;
|
||||
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
if (isScanning) {
|
||||
connector.stopScan();
|
||||
} else {
|
||||
connector.startScan();
|
||||
}
|
||||
},
|
||||
icon: isScanning
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.bluetooth_searching),
|
||||
label: Text(isScanning ? 'Stop' : 'Scan'),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
|
||||
String statusText;
|
||||
Color statusColor;
|
||||
|
||||
switch (connector.state) {
|
||||
case MeshCoreConnectionState.scanning:
|
||||
statusText = 'Scanning for devices...';
|
||||
statusColor = Colors.blue;
|
||||
break;
|
||||
case MeshCoreConnectionState.connecting:
|
||||
statusText = 'Connecting...';
|
||||
statusColor = Colors.orange;
|
||||
break;
|
||||
case MeshCoreConnectionState.connected:
|
||||
statusText = 'Connected to ${connector.device?.platformName}';
|
||||
statusColor = Colors.green;
|
||||
break;
|
||||
case MeshCoreConnectionState.disconnecting:
|
||||
statusText = 'Disconnecting...';
|
||||
statusColor = Colors.orange;
|
||||
break;
|
||||
case MeshCoreConnectionState.disconnected:
|
||||
statusText = 'Not connected';
|
||||
statusColor = Colors.grey;
|
||||
break;
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
color: statusColor.withValues(alpha: 0.1),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.circle, size: 12, color: statusColor),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
statusText,
|
||||
style: TextStyle(color: statusColor, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDeviceList(BuildContext context, MeshCoreConnector connector) {
|
||||
if (connector.scanResults.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bluetooth,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
connector.state == MeshCoreConnectionState.scanning
|
||||
? 'Searching for MeshCore devices...'
|
||||
: 'Tap Scan to find MeshCore devices',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: connector.scanResults.length,
|
||||
separatorBuilder: (context, index) => const Divider(),
|
||||
itemBuilder: (context, index) {
|
||||
final result = connector.scanResults[index];
|
||||
return DeviceTile(
|
||||
scanResult: result,
|
||||
onTap: () => _connectToDevice(context, connector, result),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _connectToDevice(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
ScanResult result,
|
||||
) async {
|
||||
try {
|
||||
await connector.connect(result.device);
|
||||
|
||||
if (context.mounted && connector.isConnected) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const DeviceScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Connection failed: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../models/radio_settings.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import 'app_settings_screen.dart';
|
||||
import 'ble_debug_log_screen.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
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),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDeviceInfoCard(MeshCoreConnector connector) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Device Info',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildInfoRow('Name', connector.device?.platformName ?? 'Unknown'),
|
||||
_buildInfoRow('ID', connector.device?.remoteId.toString() ?? 'Unknown'),
|
||||
_buildInfoRow('Status', connector.isConnected ? 'Connected' : 'Disconnected'),
|
||||
if (connector.selfName != null)
|
||||
_buildInfoRow('Node Name', connector.selfName!),
|
||||
if (connector.selfPublicKey != null)
|
||||
_buildInfoRow('Public Key', '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppSettingsCard(BuildContext context) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.settings_outlined),
|
||||
title: const Text('App Settings'),
|
||||
subtitle: const Text('Notifications, messaging, and map preferences'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const AppSettingsScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNodeSettingsCard(BuildContext context, MeshCoreConnector connector) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
'Node Settings',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_outline),
|
||||
title: const Text('Node Name'),
|
||||
subtitle: Text(connector.selfName ?? 'Not set'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _editNodeName(context, connector),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.radio),
|
||||
title: const Text('Radio Settings'),
|
||||
subtitle: const Text('Frequency, power, spreading factor'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showRadioSettings(context, connector),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.location_on_outlined),
|
||||
title: const Text('Location'),
|
||||
subtitle: const Text('GPS coordinates'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _editLocation(context, connector),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.visibility_off_outlined),
|
||||
title: const Text('Privacy Mode'),
|
||||
subtitle: const Text('Hide name/location in advertisements'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _togglePrivacy(context, connector),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionsCard(BuildContext context, MeshCoreConnector connector) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
'Actions',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cell_tower),
|
||||
title: const Text('Send Advertisement'),
|
||||
subtitle: const Text('Broadcast presence now'),
|
||||
onTap: () => _sendAdvert(context, connector),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.sync),
|
||||
title: const Text('Sync Time'),
|
||||
subtitle: const Text('Set device clock to phone time'),
|
||||
onTap: () => _syncTime(context, connector),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.refresh),
|
||||
title: const Text('Refresh Contacts'),
|
||||
subtitle: const Text('Reload contact list from device'),
|
||||
onTap: () => connector.getContacts(),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.restart_alt, color: Colors.orange),
|
||||
title: const Text('Reboot Device'),
|
||||
subtitle: const Text('Restart the MeshCore device'),
|
||||
onTap: () => _confirmReboot(context, connector),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAboutCard(BuildContext context) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('About'),
|
||||
subtitle: const Text('MeshCore Open v0.1.0'),
|
||||
onTap: () => _showAbout(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDebugCard(BuildContext context) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.bug_report_outlined),
|
||||
title: const Text('BLE Debug Log'),
|
||||
subtitle: const Text('Commands, responses, and status'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const BleDebugLogScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: Colors.grey[600])),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _editNodeName(BuildContext context, MeshCoreConnector connector) {
|
||||
final controller = TextEditingController(text: connector.selfName ?? '');
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Node Name'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Enter node name',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLength: 31,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
await connector.sendCliCommand('set name ${controller.text}');
|
||||
await connector.refreshDeviceInfo();
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Name updated')),
|
||||
);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showRadioSettings(BuildContext context, MeshCoreConnector connector) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => _RadioSettingsDialog(connector: connector),
|
||||
);
|
||||
}
|
||||
|
||||
void _editLocation(BuildContext context, MeshCoreConnector connector) {
|
||||
final latController = TextEditingController();
|
||||
final lonController = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Location'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: latController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Latitude',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: lonController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Longitude',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
var updated = false;
|
||||
if (latController.text.isNotEmpty) {
|
||||
await connector.sendCliCommand('set lat ${latController.text}');
|
||||
updated = true;
|
||||
}
|
||||
if (lonController.text.isNotEmpty) {
|
||||
await connector.sendCliCommand('set lon ${lonController.text}');
|
||||
updated = true;
|
||||
}
|
||||
if (updated) {
|
||||
await connector.refreshDeviceInfo();
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Location updated')),
|
||||
);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _togglePrivacy(BuildContext context, MeshCoreConnector connector) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Privacy Mode'),
|
||||
content: const Text('Toggle privacy mode to hide your name and location in advertisements.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
await connector.sendCliCommand('set privacy on');
|
||||
await connector.refreshDeviceInfo();
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Privacy mode enabled')),
|
||||
);
|
||||
},
|
||||
child: const Text('Enable'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
await connector.sendCliCommand('set privacy off');
|
||||
await connector.refreshDeviceInfo();
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Privacy mode disabled')),
|
||||
);
|
||||
},
|
||||
child: const Text('Disable'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _sendAdvert(BuildContext context, MeshCoreConnector connector) {
|
||||
connector.sendCliCommand('advert');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Advertisement sent')),
|
||||
);
|
||||
}
|
||||
|
||||
void _syncTime(BuildContext context, MeshCoreConnector connector) {
|
||||
connector.syncTime();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Time synchronized')),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmReboot(BuildContext context, MeshCoreConnector connector) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Reboot Device'),
|
||||
content: const Text('Are you sure you want to reboot the device? You will be disconnected.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
connector.sendCliCommand('reboot');
|
||||
},
|
||||
child: const Text('Reboot', style: TextStyle(color: Colors.orange)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAbout(BuildContext context) {
|
||||
showAboutDialog(
|
||||
context: context,
|
||||
applicationName: 'MeshCore Open',
|
||||
applicationVersion: '0.1.0',
|
||||
applicationLegalese: '2024 MeshCore Open Source Project',
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'An open-source Flutter client for MeshCore LoRa mesh networking devices.',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RadioSettingsDialog extends StatefulWidget {
|
||||
final MeshCoreConnector connector;
|
||||
|
||||
const _RadioSettingsDialog({required this.connector});
|
||||
|
||||
@override
|
||||
State<_RadioSettingsDialog> createState() => _RadioSettingsDialogState();
|
||||
}
|
||||
|
||||
class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
|
||||
final _frequencyController = TextEditingController();
|
||||
LoRaBandwidth _bandwidth = LoRaBandwidth.bw125;
|
||||
LoRaSpreadingFactor _spreadingFactor = LoRaSpreadingFactor.sf7;
|
||||
LoRaCodingRate _codingRate = LoRaCodingRate.cr4_5;
|
||||
final _txPowerController = TextEditingController(text: '20');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Populate with current settings if available
|
||||
if (widget.connector.currentFreqHz != null) {
|
||||
_frequencyController.text = (widget.connector.currentFreqHz! / 1000.0).toStringAsFixed(3);
|
||||
} else {
|
||||
_frequencyController.text = '915.0';
|
||||
}
|
||||
|
||||
if (widget.connector.currentBwHz != null) {
|
||||
// Find matching bandwidth enum
|
||||
final bwValue = widget.connector.currentBwHz!;
|
||||
for (var bw in LoRaBandwidth.values) {
|
||||
if (bw.hz == bwValue) {
|
||||
_bandwidth = bw;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (widget.connector.currentSf != null) {
|
||||
// Find matching spreading factor enum
|
||||
final sfValue = widget.connector.currentSf!;
|
||||
for (var sf in LoRaSpreadingFactor.values) {
|
||||
if (sf.value == sfValue) {
|
||||
_spreadingFactor = sf;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (widget.connector.currentCr != null) {
|
||||
// Find matching coding rate enum
|
||||
final crValue = _toUiCodingRate(widget.connector.currentCr!);
|
||||
for (var cr in LoRaCodingRate.values) {
|
||||
if (cr.value == crValue) {
|
||||
_codingRate = cr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (widget.connector.currentTxPower != null) {
|
||||
_txPowerController.text = widget.connector.currentTxPower.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_frequencyController.dispose();
|
||||
_txPowerController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _applyPreset(RadioSettings preset) {
|
||||
setState(() {
|
||||
_frequencyController.text = preset.frequencyMHz.toString();
|
||||
_bandwidth = preset.bandwidth;
|
||||
_spreadingFactor = preset.spreadingFactor;
|
||||
_codingRate = preset.codingRate;
|
||||
_txPowerController.text = preset.txPowerDbm.toString();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveSettings() async {
|
||||
final freqMHz = double.tryParse(_frequencyController.text);
|
||||
final txPower = int.tryParse(_txPowerController.text);
|
||||
|
||||
if (freqMHz == null || freqMHz < 300 || freqMHz > 2500) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Invalid frequency (300-2500 MHz)')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (txPower == null || txPower < 0 || txPower > 22) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Invalid TX power (0-22 dBm)')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final freqHz = (freqMHz * 1000).round();
|
||||
final bwHz = _bandwidth.hz;
|
||||
final sf = _spreadingFactor.value;
|
||||
final cr = _toDeviceCodingRate(_codingRate.value, widget.connector.currentCr);
|
||||
|
||||
try {
|
||||
await widget.connector.sendFrame(buildSetRadioParamsFrame(freqHz, bwHz, sf, cr));
|
||||
await widget.connector.sendFrame(buildSetRadioTxPowerFrame(txPower));
|
||||
await widget.connector.refreshDeviceInfo();
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Radio settings updated')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int _toUiCodingRate(int deviceCr) {
|
||||
return deviceCr <= 4 ? deviceCr + 4 : deviceCr;
|
||||
}
|
||||
|
||||
int _toDeviceCodingRate(int uiCr, int? deviceCr) {
|
||||
if (deviceCr != null && deviceCr <= 4) {
|
||||
return uiCr - 4;
|
||||
}
|
||||
return uiCr;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Radio Settings'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Presets', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
_PresetChip(
|
||||
label: '915 MHz',
|
||||
onTap: () => _applyPreset(RadioSettings.preset915MHz),
|
||||
),
|
||||
_PresetChip(
|
||||
label: '868 MHz',
|
||||
onTap: () => _applyPreset(RadioSettings.preset868MHz),
|
||||
),
|
||||
_PresetChip(
|
||||
label: '433 MHz',
|
||||
onTap: () => _applyPreset(RadioSettings.preset433MHz),
|
||||
),
|
||||
_PresetChip(
|
||||
label: 'Long Range',
|
||||
onTap: () => _applyPreset(RadioSettings.presetLongRange),
|
||||
),
|
||||
_PresetChip(
|
||||
label: 'Fast Speed',
|
||||
onTap: () => _applyPreset(RadioSettings.presetFastSpeed),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _frequencyController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Frequency (MHz)',
|
||||
border: OutlineInputBorder(),
|
||||
helperText: '300.0 - 2500.0',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<LoRaBandwidth>(
|
||||
value: _bandwidth,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Bandwidth',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: LoRaBandwidth.values
|
||||
.map((bw) => DropdownMenuItem(
|
||||
value: bw,
|
||||
child: Text(bw.label),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => _bandwidth = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<LoRaSpreadingFactor>(
|
||||
value: _spreadingFactor,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Spreading Factor',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: LoRaSpreadingFactor.values
|
||||
.map((sf) => DropdownMenuItem(
|
||||
value: sf,
|
||||
child: Text(sf.label),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => _spreadingFactor = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<LoRaCodingRate>(
|
||||
value: _codingRate,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Coding Rate',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: LoRaCodingRate.values
|
||||
.map((cr) => DropdownMenuItem(
|
||||
value: cr,
|
||||
child: Text(cr.label),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => _codingRate = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _txPowerController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'TX Power (dBm)',
|
||||
border: OutlineInputBorder(),
|
||||
helperText: '0 - 22',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _saveSettings,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PresetChip extends StatelessWidget {
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _PresetChip({required this.label, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ActionChip(
|
||||
label: Text(label),
|
||||
onPressed: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user