mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-03 15:30:57 +10:00
Add advanced path management, debug logging, and fix channel sync
New features: - In-app debug log viewer with copy/clear functionality - Advanced path management UI with history and custom path builder - Battery indicator widget with voltage/percentage toggle - Contact/channel filtering and sorting improvements - Repeater command ACK tracking with path history integration Fixes: - Switch channel sync from parallel to sequential to prevent timeouts - Preserve path overrides when contacts refresh from device - Fix ACK hash computation for SMAZ-encoded messages - Proper cleanup of pending operations on disconnect
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/app_debug_log_service.dart';
|
||||
|
||||
class AppDebugLogScreen extends StatelessWidget {
|
||||
const AppDebugLogScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<AppDebugLogService>(
|
||||
builder: (context, logService, _) {
|
||||
final entries = logService.entries.reversed.toList();
|
||||
final hasEntries = entries.isNotEmpty;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('App Debug Log'),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Copy log',
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: hasEntries
|
||||
? () async {
|
||||
final text = entries
|
||||
.map((entry) =>
|
||||
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}')
|
||||
.join('\n');
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Debug log copied')),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Clear log',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: hasEntries
|
||||
? () {
|
||||
logService.clear();
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: hasEntries
|
||||
? ListView.separated(
|
||||
itemCount: entries.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: _buildLevelIcon(entry.level),
|
||||
title: Text(
|
||||
'[${entry.tag}] ${entry.message}',
|
||||
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
|
||||
),
|
||||
subtitle: Text(
|
||||
entry.formattedTime,
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.bug_report_outlined, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No debug logs yet',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Enable app debug logging in settings',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLevelIcon(AppDebugLogLevel level) {
|
||||
switch (level) {
|
||||
case AppDebugLogLevel.info:
|
||||
return const Icon(Icons.info_outline, size: 18, color: Colors.blue);
|
||||
case AppDebugLogLevel.warning:
|
||||
return const Icon(Icons.warning_amber_outlined, size: 18, color: Colors.orange);
|
||||
case AppDebugLogLevel.error:
|
||||
return const Icon(Icons.error_outline, size: 18, color: Colors.red);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
_buildBatteryCard(context, settingsService, connector),
|
||||
const SizedBox(height: 16),
|
||||
_buildMapSettingsCard(context, settingsService),
|
||||
const SizedBox(height: 16),
|
||||
_buildDebugCard(context, settingsService),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -383,43 +385,31 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
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);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
content: RadioGroup<String>(
|
||||
groupValue: settingsService.settings.themeMode,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsService.setThemeMode(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RadioListTile<String>(
|
||||
title: const Text('System default'),
|
||||
value: 'system',
|
||||
),
|
||||
RadioListTile<String>(
|
||||
title: const Text('Light'),
|
||||
value: 'light',
|
||||
),
|
||||
RadioListTile<String>(
|
||||
title: const Text('Dark'),
|
||||
value: 'dark',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -447,77 +437,51 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
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);
|
||||
}
|
||||
},
|
||||
content: RadioGroup<double>(
|
||||
groupValue: settingsService.settings.mapTimeFilterHours,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsService.setMapTimeFilterHours(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
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 hour'),
|
||||
leading: Radio<double>(
|
||||
value: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
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 6 hours'),
|
||||
leading: Radio<double>(
|
||||
value: 6,
|
||||
),
|
||||
),
|
||||
),
|
||||
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 24 hours'),
|
||||
leading: Radio<double>(
|
||||
value: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
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);
|
||||
}
|
||||
},
|
||||
ListTile(
|
||||
title: const Text('Last week'),
|
||||
leading: Radio<double>(
|
||||
value: 168,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -528,4 +492,39 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDebugCard(BuildContext context, AppSettingsService settingsService) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
'Debug',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.bug_report_outlined),
|
||||
title: const Text('App Debug Logging'),
|
||||
subtitle: const Text('Log app debug messages for troubleshooting'),
|
||||
value: settingsService.settings.appDebugLogEnabled,
|
||||
onChanged: (value) async {
|
||||
await settingsService.setAppDebugLogEnabled(value);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(value
|
||||
? 'App debug logging enabled'
|
||||
: 'App debug logging disabled'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -38,11 +39,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
context.read<MeshCoreConnector>().setActiveChannel(widget.channel.index);
|
||||
|
||||
// Scroll to bottom when opening channel chat
|
||||
// Scroll to bottom when opening channel chat - use SchedulerBinding for next frame
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
||||
}
|
||||
@@ -151,7 +152,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
builder: (context, connector, child) {
|
||||
final messages = connector.getChannelMessages(widget.channel);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToBottom();
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import '../models/channel.dart';
|
||||
import '../utils/dialog_utils.dart';
|
||||
import '../utils/disconnect_navigation_mixin.dart';
|
||||
import '../utils/route_transitions.dart';
|
||||
import '../widgets/battery_indicator.dart';
|
||||
import '../widgets/list_filter_widget.dart';
|
||||
import '../widgets/empty_state.dart';
|
||||
import '../widgets/quick_switch_bar.dart';
|
||||
import '../widgets/unread_badge.dart';
|
||||
@@ -18,6 +20,13 @@ import 'contacts_screen.dart';
|
||||
import 'map_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
enum ChannelSortOption {
|
||||
manual,
|
||||
name,
|
||||
latestMessages,
|
||||
unread,
|
||||
}
|
||||
|
||||
class ChannelsScreen extends StatefulWidget {
|
||||
final bool hideBackButton;
|
||||
|
||||
@@ -32,6 +41,11 @@ class ChannelsScreen extends StatefulWidget {
|
||||
|
||||
class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
with DisconnectNavigationMixin {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
Timer? _searchDebounce;
|
||||
ChannelSortOption _sortOption = ChannelSortOption.manual;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -40,6 +54,13 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchDebounce?.cancel();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
@@ -55,10 +76,16 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
canPop: allowBack,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: BatteryIndicator(connector: connector),
|
||||
title: const Text('Channels'),
|
||||
centerTitle: true,
|
||||
automaticallyImplyLeading: !widget.hideBackButton && allowBack,
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bluetooth_disabled),
|
||||
tooltip: 'Disconnect',
|
||||
onPressed: () => _disconnect(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.tune),
|
||||
tooltip: 'Settings',
|
||||
@@ -67,57 +94,154 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bluetooth_disabled),
|
||||
tooltip: 'Disconnect',
|
||||
onPressed: () => _disconnect(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => context.read<MeshCoreConnector>().getChannels(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: () {
|
||||
if (connector.isLoadingChannels) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await context.read<MeshCoreConnector>().getChannels();
|
||||
},
|
||||
child: () {
|
||||
if (connector.isLoadingChannels) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final channels = connector.channels;
|
||||
final channels = connector.channels;
|
||||
|
||||
if (channels.isEmpty) {
|
||||
return EmptyState(
|
||||
icon: Icons.tag,
|
||||
title: 'No channels configured',
|
||||
action: FilledButton.icon(
|
||||
onPressed: () => _addPublicChannel(context, connector),
|
||||
icon: const Icon(Icons.public),
|
||||
label: const Text('Add Public Channel'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ReorderableListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 88),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: channels.length,
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
if (newIndex > oldIndex) newIndex -= 1;
|
||||
final reordered = List<Channel>.from(channels);
|
||||
final item = reordered.removeAt(oldIndex);
|
||||
reordered.insert(newIndex, item);
|
||||
unawaited(
|
||||
connector.setChannelOrder(
|
||||
reordered.map((c) => c.index).toList(),
|
||||
),
|
||||
if (channels.isEmpty) {
|
||||
return ListView(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).size.height - 200,
|
||||
child: EmptyState(
|
||||
icon: Icons.tag,
|
||||
title: 'No channels configured',
|
||||
action: FilledButton.icon(
|
||||
onPressed: () => _addPublicChannel(context, connector),
|
||||
icon: const Icon(Icons.public),
|
||||
label: const Text('Add Public Channel'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final channel = channels[index];
|
||||
return _buildChannelTile(context, connector, channel, index);
|
||||
},
|
||||
);
|
||||
}(),
|
||||
}
|
||||
|
||||
final filteredChannels = _filterAndSortChannels(channels, connector);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search channels...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_searchQuery.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
});
|
||||
},
|
||||
),
|
||||
_buildFilterButton(),
|
||||
],
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
onChanged: (value) {
|
||||
_searchDebounce?.cancel();
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_searchQuery = value.toLowerCase();
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: filteredChannels.isEmpty
|
||||
? ListView(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).size.height - 300,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No channels found',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: (_sortOption == ChannelSortOption.manual && _searchQuery.isEmpty)
|
||||
? ReorderableListView.builder(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 8,
|
||||
bottom: 88,
|
||||
),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: filteredChannels.length,
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
if (newIndex > oldIndex) newIndex -= 1;
|
||||
final reordered = List<Channel>.from(filteredChannels);
|
||||
final item = reordered.removeAt(oldIndex);
|
||||
reordered.insert(newIndex, item);
|
||||
unawaited(
|
||||
connector.setChannelOrder(
|
||||
reordered.map((c) => c.index).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final channel = filteredChannels[index];
|
||||
return _buildChannelTile(
|
||||
context,
|
||||
connector,
|
||||
channel,
|
||||
showDragHandle: true,
|
||||
dragIndex: index,
|
||||
);
|
||||
},
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 8,
|
||||
bottom: 88,
|
||||
),
|
||||
itemCount: filteredChannels.length,
|
||||
itemBuilder: (context, index) {
|
||||
final channel = filteredChannels[index];
|
||||
return _buildChannelTile(context, connector, channel);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}(),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showAddChannelDialog(context),
|
||||
child: const Icon(Icons.add),
|
||||
@@ -137,7 +261,10 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
Channel channel,
|
||||
int index,
|
||||
{
|
||||
bool showDragHandle = false,
|
||||
int? dragIndex,
|
||||
}
|
||||
) {
|
||||
final unreadCount = connector.getUnreadCountForChannel(channel);
|
||||
return Card(
|
||||
@@ -179,13 +306,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
UnreadBadge(count: unreadCount),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
ReorderableDelayedDragStartListener(
|
||||
index: index,
|
||||
child: Icon(
|
||||
Icons.drag_handle,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
if (showDragHandle && dragIndex != null)
|
||||
ReorderableDelayedDragStartListener(
|
||||
index: dragIndex,
|
||||
child: Icon(
|
||||
Icons.drag_handle,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
@@ -271,6 +399,118 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
await showDisconnectDialog(context, connector);
|
||||
}
|
||||
|
||||
Widget _buildFilterButton() {
|
||||
const actionSortManual = 0;
|
||||
const actionSortName = 1;
|
||||
const actionSortLatest = 2;
|
||||
const actionSortUnread = 3;
|
||||
|
||||
return SortFilterMenu(
|
||||
sections: [
|
||||
SortFilterMenuSection(
|
||||
title: 'Sort by',
|
||||
options: [
|
||||
SortFilterMenuOption(
|
||||
value: actionSortManual,
|
||||
label: 'Manual',
|
||||
checked: _sortOption == ChannelSortOption.manual,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: actionSortName,
|
||||
label: 'A-Z',
|
||||
checked: _sortOption == ChannelSortOption.name,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: actionSortLatest,
|
||||
label: 'Latest messages',
|
||||
checked: _sortOption == ChannelSortOption.latestMessages,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: actionSortUnread,
|
||||
label: 'Unread',
|
||||
checked: _sortOption == ChannelSortOption.unread,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
onSelected: (action) {
|
||||
setState(() {
|
||||
switch (action) {
|
||||
case actionSortManual:
|
||||
_sortOption = ChannelSortOption.manual;
|
||||
break;
|
||||
case actionSortLatest:
|
||||
_sortOption = ChannelSortOption.latestMessages;
|
||||
break;
|
||||
case actionSortUnread:
|
||||
_sortOption = ChannelSortOption.unread;
|
||||
break;
|
||||
case actionSortName:
|
||||
default:
|
||||
_sortOption = ChannelSortOption.name;
|
||||
break;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Channel> _filterAndSortChannels(
|
||||
List<Channel> channels,
|
||||
MeshCoreConnector connector,
|
||||
) {
|
||||
var filtered = channels.where((channel) {
|
||||
if (_searchQuery.isEmpty) return true;
|
||||
final label = _normalizeChannelName(channel);
|
||||
return label.toLowerCase().contains(_searchQuery);
|
||||
}).toList();
|
||||
|
||||
int compareByName(Channel a, Channel b) {
|
||||
final nameA = _normalizeChannelName(a);
|
||||
final nameB = _normalizeChannelName(b);
|
||||
return nameA.toLowerCase().compareTo(nameB.toLowerCase());
|
||||
}
|
||||
|
||||
switch (_sortOption) {
|
||||
case ChannelSortOption.manual:
|
||||
break;
|
||||
case ChannelSortOption.latestMessages:
|
||||
filtered.sort((a, b) {
|
||||
final aMessages = connector.getChannelMessages(a);
|
||||
final bMessages = connector.getChannelMessages(b);
|
||||
final aLast = aMessages.isEmpty ? DateTime(1970) : aMessages.last.timestamp;
|
||||
final bLast = bMessages.isEmpty ? DateTime(1970) : bMessages.last.timestamp;
|
||||
final timeCompare = bLast.compareTo(aLast);
|
||||
if (timeCompare != 0) return timeCompare;
|
||||
return compareByName(a, b);
|
||||
});
|
||||
break;
|
||||
case ChannelSortOption.unread:
|
||||
filtered.sort((a, b) {
|
||||
final aUnread = connector.getUnreadCountForChannel(a);
|
||||
final bUnread = connector.getUnreadCountForChannel(b);
|
||||
final unreadCompare = bUnread.compareTo(aUnread);
|
||||
if (unreadCompare != 0) return unreadCompare;
|
||||
return compareByName(a, b);
|
||||
});
|
||||
break;
|
||||
case ChannelSortOption.name:
|
||||
filtered.sort(compareByName);
|
||||
break;
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
String _normalizeChannelName(Channel channel) {
|
||||
if (channel.name.isEmpty) return 'Channel ${channel.index}';
|
||||
final trimmed = channel.name.trim();
|
||||
if (trimmed.startsWith('#') && trimmed.length > 1) {
|
||||
return trimmed.substring(1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
void _showAddChannelDialog(BuildContext context) {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final nameController = TextEditingController();
|
||||
|
||||
+72
-382
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
@@ -19,6 +20,8 @@ import '../utils/emoji_utils.dart';
|
||||
import '../widgets/emoji_picker.dart';
|
||||
import '../widgets/gif_message.dart';
|
||||
import '../widgets/gif_picker.dart';
|
||||
import '../widgets/path_selection_dialog.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
final Contact contact;
|
||||
@@ -36,11 +39,11 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
context.read<MeshCoreConnector>().setActiveContact(widget.contact.publicKeyHex);
|
||||
|
||||
// Scroll to bottom when opening chat
|
||||
// Scroll to bottom when opening chat use SchedulerBinding for next frame
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
||||
}
|
||||
@@ -438,13 +441,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Using ${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'} path'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
await _notifyPathSet(
|
||||
connector,
|
||||
widget.contact,
|
||||
pathBytes,
|
||||
path.hopCount,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -590,6 +593,30 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
return '${contact.pathLength} hops';
|
||||
}
|
||||
|
||||
Future<void> _notifyPathSet(
|
||||
MeshCoreConnector connector,
|
||||
Contact contact,
|
||||
Uint8List pathBytes,
|
||||
int hopCount,
|
||||
) async {
|
||||
final verified = connector.isConnected
|
||||
? await connector.verifyContactPathOnDevice(contact, pathBytes)
|
||||
: false;
|
||||
if (!mounted) return;
|
||||
|
||||
final status = !connector.isConnected
|
||||
? 'Saved locally. Connect to sync.'
|
||||
: (verified ? 'Device confirmed.' : 'Device not confirmed yet.');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Path set: $hopCount ${hopCount == 1 ? 'hop' : 'hops'} - $status',
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showContactInfo(BuildContext context) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex);
|
||||
@@ -657,7 +684,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showCustomPathDialog(BuildContext context) {
|
||||
Future<void> _showCustomPathDialog(BuildContext context) async {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
|
||||
final currentContact = _resolveContact(connector);
|
||||
@@ -665,385 +692,48 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
connector.getContacts();
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.edit_road),
|
||||
SizedBox(width: 8),
|
||||
Text('Set Custom Path'),
|
||||
],
|
||||
),
|
||||
content: Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final contact = _resolveContact(connector);
|
||||
final pathForInput = contact.pathIdList;
|
||||
final currentPathLabel = _currentPathLabel(contact);
|
||||
final pathForInput = currentContact.pathIdList;
|
||||
final currentPathLabel = _currentPathLabel(currentContact);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Current path',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: connector.isConnected ? connector.getContacts : null,
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
label: const Text('Reload'),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
currentPathLabel,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Choose how to set the message path:',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.blue,
|
||||
child: Icon(Icons.text_fields, size: 16),
|
||||
),
|
||||
title: const Text('Enter Path Manually', style: TextStyle(fontSize: 14)),
|
||||
subtitle: const Text('Type IDs like: A1B2C3D4,FFEEDDCC', style: TextStyle(fontSize: 11)),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showManualPathInput(
|
||||
context,
|
||||
initialPath: pathForInput.isEmpty ? null : pathForInput,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.green,
|
||||
child: Icon(Icons.contacts, size: 16),
|
||||
),
|
||||
title: const Text('Select from Contacts', style: TextStyle(fontSize: 14)),
|
||||
subtitle: const Text('Pick repeaters/rooms as hops', style: TextStyle(fontSize: 11)),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showContactPathPicker(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showManualPathInput(BuildContext context, {String? initialPath}) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final controller = TextEditingController(text: initialPath ?? '');
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Enter Custom Path'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Enter 2-character hex prefixes for each hop, separated by commas.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Example: A1,F2,3C (each node uses first byte of its public key)',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Path (hex prefixes)',
|
||||
hintText: 'A1,F2,3C',
|
||||
border: OutlineInputBorder(),
|
||||
helperText: 'Max 64 hops. Each prefix is 2 hex characters (1 byte)',
|
||||
),
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
maxLength: 191, // 64 hops * 2 chars + 63 commas
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final path = controller.text.trim().toUpperCase();
|
||||
if (path.isEmpty) {
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse comma-separated hex prefixes
|
||||
final pathIds = path.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
||||
final pathBytesList = <int>[];
|
||||
final invalidPrefixes = <String>[];
|
||||
|
||||
for (final id in pathIds) {
|
||||
if (id.length < 2) {
|
||||
invalidPrefixes.add(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
final prefix = id.substring(0, 2);
|
||||
try {
|
||||
final byte = int.parse(prefix, radix: 16);
|
||||
pathBytesList.add(byte);
|
||||
} catch (e) {
|
||||
invalidPrefixes.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Show error for invalid prefixes
|
||||
if (invalidPrefixes.isNotEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Invalid hex prefixes: ${invalidPrefixes.join(", ")}'),
|
||||
duration: const Duration(seconds: 3),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check max path length (64 hops)
|
||||
if (pathBytesList.length > 64) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Path too long. Maximum 64 hops allowed.'),
|
||||
duration: Duration(seconds: 3),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathBytesList.isNotEmpty) {
|
||||
await connector.setContactPath(
|
||||
widget.contact,
|
||||
Uint8List.fromList(pathBytesList),
|
||||
pathBytesList.length,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Path set: ${pathBytesList.length} ${pathBytesList.length == 1 ? "hop" : "hops"}'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Set Path'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showContactPathPicker(BuildContext context) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final selectedContacts = <Contact>[];
|
||||
|
||||
// Filter to only repeaters and room servers
|
||||
final validContacts = connector.contacts
|
||||
.where((c) => (c.type == 2 || c.type == 3) && c != widget.contact)
|
||||
// Filter out the current contact from available contacts
|
||||
final availableContacts = connector.contacts
|
||||
.where((c) => c != widget.contact)
|
||||
.toList();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialogState) => AlertDialog(
|
||||
title: const Text('Build Path from Contacts'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (validContacts.isEmpty) ...[
|
||||
const Icon(Icons.info_outline, size: 48, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'No repeaters or room servers found.',
|
||||
style: TextStyle(fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Custom paths require intermediate hops that can relay messages.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
] else if (selectedContacts.isNotEmpty) ...[
|
||||
const Text(
|
||||
'Selected Path:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: selectedContacts.asMap().entries.map((entry) {
|
||||
final idx = entry.key;
|
||||
final contact = entry.value;
|
||||
return Chip(
|
||||
avatar: CircleAvatar(
|
||||
child: Text('${idx + 1}'),
|
||||
),
|
||||
label: Text(contact.name),
|
||||
onDeleted: () {
|
||||
setDialogState(() {
|
||||
selectedContacts.removeAt(idx);
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const Divider(),
|
||||
] else
|
||||
const Text(
|
||||
'Tap repeaters/rooms to add them to the path:',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (validContacts.isNotEmpty)
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: validContacts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final contact = validContacts[index];
|
||||
final isSelected = selectedContacts.contains(contact);
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: isSelected ? Colors.green : (contact.type == 2 ? Colors.blue : Colors.purple),
|
||||
child: Icon(
|
||||
contact.type == 2 ? Icons.router : Icons.meeting_room,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
title: Text(contact.name, style: const TextStyle(fontSize: 14)),
|
||||
subtitle: Text(
|
||||
'${contact.typeLabel} • ${contact.publicKeyHex.substring(0, 8)}',
|
||||
style: const TextStyle(fontSize: 10),
|
||||
),
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check_circle, color: Colors.green)
|
||||
: const Icon(Icons.add_circle_outline),
|
||||
onTap: () {
|
||||
setDialogState(() {
|
||||
if (isSelected) {
|
||||
selectedContacts.remove(contact);
|
||||
} else {
|
||||
selectedContacts.add(contact);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (selectedContacts.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setDialogState(() {
|
||||
selectedContacts.clear();
|
||||
});
|
||||
},
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: selectedContacts.isEmpty
|
||||
? null
|
||||
: () async {
|
||||
// Build path bytes from selected contacts (prefix byte of each pub key)
|
||||
final pathBytesList = <int>[];
|
||||
for (final contact in selectedContacts) {
|
||||
if (contact.publicKeyHex.length >= 2) {
|
||||
try {
|
||||
pathBytesList.add(int.parse(contact.publicKeyHex.substring(0, 2), radix: 16));
|
||||
} catch (e) {
|
||||
// Skip invalid hex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pathBytesList.isNotEmpty) {
|
||||
await connector.setContactPath(
|
||||
widget.contact,
|
||||
Uint8List.fromList(pathBytesList),
|
||||
pathBytesList.length,
|
||||
);
|
||||
|
||||
final pathIds = selectedContacts
|
||||
.map((c) => c.publicKeyHex.substring(0, 8))
|
||||
.join(',');
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Custom path set: $pathIds'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Set Path'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
final result = await PathSelectionDialog.show(
|
||||
context,
|
||||
availableContacts: availableContacts,
|
||||
initialPath: pathForInput.isEmpty ? null : pathForInput,
|
||||
title: 'Set Custom Path',
|
||||
currentPathLabel: currentPathLabel,
|
||||
onRefresh: connector.isConnected ? connector.getContacts : null,
|
||||
);
|
||||
|
||||
appLogger.info('PathSelectionDialog returned: ${result?.length ?? 0} bytes, mounted: $mounted', tag: 'ChatScreen');
|
||||
|
||||
if (result == null) {
|
||||
appLogger.info('PathSelectionDialog was cancelled or returned null', tag: 'ChatScreen');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
appLogger.warn('Widget not mounted after dialog, cannot set path', tag: 'ChatScreen');
|
||||
return;
|
||||
}
|
||||
|
||||
appLogger.info('Calling setPathOverride for ${widget.contact.name}', tag: 'ChatScreen');
|
||||
await connector.setPathOverride(
|
||||
widget.contact,
|
||||
pathLen: result.length,
|
||||
pathBytes: result,
|
||||
);
|
||||
appLogger.info('setPathOverride completed', tag: 'ChatScreen');
|
||||
|
||||
if (!mounted) return;
|
||||
await _notifyPathSet(connector, widget.contact, result, result.length);
|
||||
}
|
||||
|
||||
|
||||
void _openMessagePath(Message message) {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final senderName =
|
||||
|
||||
@@ -13,6 +13,8 @@ import '../utils/dialog_utils.dart';
|
||||
import '../utils/disconnect_navigation_mixin.dart';
|
||||
import '../utils/emoji_utils.dart';
|
||||
import '../utils/route_transitions.dart';
|
||||
import '../widgets/battery_indicator.dart';
|
||||
import '../widgets/list_filter_widget.dart';
|
||||
import '../widgets/empty_state.dart';
|
||||
import '../widgets/quick_switch_bar.dart';
|
||||
import '../widgets/repeater_login_dialog.dart';
|
||||
@@ -23,22 +25,6 @@ import 'map_screen.dart';
|
||||
import 'repeater_hub_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
enum ContactSortOption {
|
||||
lastSeen,
|
||||
recentMessages,
|
||||
name,
|
||||
type,
|
||||
}
|
||||
|
||||
enum _ContactMenuAction {
|
||||
sortRecentMessages,
|
||||
sortName,
|
||||
sortType,
|
||||
toggleLastSeenFilter,
|
||||
toggleUnreadOnly,
|
||||
newGroup,
|
||||
}
|
||||
|
||||
class ContactsScreen extends StatefulWidget {
|
||||
final bool hideBackButton;
|
||||
|
||||
@@ -56,8 +42,8 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
ContactSortOption _sortOption = ContactSortOption.lastSeen;
|
||||
bool _forceLastSeenSort = true;
|
||||
bool _showUnreadOnly = false;
|
||||
ContactTypeFilter _typeFilter = ContactTypeFilter.all;
|
||||
final ContactGroupStore _groupStore = ContactGroupStore();
|
||||
List<ContactGroup> _groups = [];
|
||||
Timer? _searchDebounce;
|
||||
@@ -97,41 +83,15 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
}
|
||||
|
||||
final allowBack = !connector.isConnected;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: allowBack,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
titleSpacing: 16,
|
||||
centerTitle: false,
|
||||
automaticallyImplyLeading: !widget.hideBackButton && allowBack,
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Contacts'),
|
||||
Text(
|
||||
'${connector.contacts.length} contacts',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
leading: BatteryIndicator(connector: connector),
|
||||
title: const Text('Contacts'),
|
||||
centerTitle: true,
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: connector.isLoadingContacts
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
tooltip: 'Refresh',
|
||||
onPressed: connector.isLoadingContacts ? null : () => connector.getContacts(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bluetooth_disabled),
|
||||
tooltip: 'Disconnect',
|
||||
@@ -145,93 +105,6 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||
),
|
||||
),
|
||||
PopupMenuButton<_ContactMenuAction>(
|
||||
tooltip: 'Contacts options',
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case _ContactMenuAction.sortRecentMessages:
|
||||
setState(() {
|
||||
_sortOption = ContactSortOption.recentMessages;
|
||||
_forceLastSeenSort = false;
|
||||
});
|
||||
break;
|
||||
case _ContactMenuAction.sortName:
|
||||
setState(() {
|
||||
_sortOption = ContactSortOption.name;
|
||||
_forceLastSeenSort = false;
|
||||
});
|
||||
break;
|
||||
case _ContactMenuAction.sortType:
|
||||
setState(() {
|
||||
_sortOption = ContactSortOption.type;
|
||||
_forceLastSeenSort = false;
|
||||
});
|
||||
break;
|
||||
case _ContactMenuAction.toggleLastSeenFilter:
|
||||
setState(() {
|
||||
_forceLastSeenSort = !_forceLastSeenSort;
|
||||
if (_forceLastSeenSort) {
|
||||
_sortOption = ContactSortOption.lastSeen;
|
||||
}
|
||||
});
|
||||
break;
|
||||
case _ContactMenuAction.toggleUnreadOnly:
|
||||
setState(() {
|
||||
_showUnreadOnly = !_showUnreadOnly;
|
||||
});
|
||||
break;
|
||||
case _ContactMenuAction.newGroup:
|
||||
_showGroupEditor(context, connector.contacts);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) {
|
||||
final labelStyle = theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
return [
|
||||
PopupMenuItem<_ContactMenuAction>(
|
||||
enabled: false,
|
||||
child: Text('Sort by', style: labelStyle),
|
||||
),
|
||||
CheckedPopupMenuItem<_ContactMenuAction>(
|
||||
value: _ContactMenuAction.sortRecentMessages,
|
||||
checked: _sortOption == ContactSortOption.recentMessages,
|
||||
child: const Text('Recent messages'),
|
||||
),
|
||||
CheckedPopupMenuItem<_ContactMenuAction>(
|
||||
value: _ContactMenuAction.sortName,
|
||||
checked: _sortOption == ContactSortOption.name,
|
||||
child: const Text('Name'),
|
||||
),
|
||||
CheckedPopupMenuItem<_ContactMenuAction>(
|
||||
value: _ContactMenuAction.sortType,
|
||||
checked: _sortOption == ContactSortOption.type,
|
||||
child: const Text('Type'),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem<_ContactMenuAction>(
|
||||
enabled: false,
|
||||
child: Text('Filters', style: labelStyle),
|
||||
),
|
||||
CheckedPopupMenuItem<_ContactMenuAction>(
|
||||
value: _ContactMenuAction.toggleLastSeenFilter,
|
||||
checked: _forceLastSeenSort,
|
||||
child: const Text('Last seen'),
|
||||
),
|
||||
CheckedPopupMenuItem<_ContactMenuAction>(
|
||||
value: _ContactMenuAction.toggleUnreadOnly,
|
||||
checked: _showUnreadOnly,
|
||||
child: const Text('Unread only'),
|
||||
),
|
||||
PopupMenuItem<_ContactMenuAction>(
|
||||
value: _ContactMenuAction.newGroup,
|
||||
child: const Text('New group'),
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _buildContactsBody(context, connector),
|
||||
@@ -253,6 +126,30 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
await showDisconnectDialog(context, connector);
|
||||
}
|
||||
|
||||
Widget _buildFilterButton(BuildContext context, MeshCoreConnector connector) {
|
||||
return ContactsFilterMenu(
|
||||
sortOption: _sortOption,
|
||||
typeFilter: _typeFilter,
|
||||
showUnreadOnly: _showUnreadOnly,
|
||||
onSortChanged: (value) {
|
||||
setState(() {
|
||||
_sortOption = value;
|
||||
});
|
||||
},
|
||||
onTypeFilterChanged: (value) {
|
||||
setState(() {
|
||||
_typeFilter = value;
|
||||
});
|
||||
},
|
||||
onUnreadOnlyChanged: (value) {
|
||||
setState(() {
|
||||
_showUnreadOnly = value;
|
||||
});
|
||||
},
|
||||
onNewGroup: () => _showGroupEditor(context, connector.contacts),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
|
||||
final contacts = connector.contacts;
|
||||
|
||||
@@ -281,8 +178,11 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search contacts...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_searchQuery.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
@@ -290,8 +190,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
_searchQuery = '';
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
_buildFilterButton(context, connector),
|
||||
],
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
@@ -366,6 +268,13 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
if (contact != null && matchesContactQuery(contact, query)) return true;
|
||||
}
|
||||
return false;
|
||||
}).where((group) {
|
||||
if (_typeFilter == ContactTypeFilter.all) return true;
|
||||
for (final key in group.memberKeys) {
|
||||
final contact = contactsByKey[key];
|
||||
if (contact != null && _matchesTypeFilter(contact)) return true;
|
||||
}
|
||||
return false;
|
||||
}).toList();
|
||||
|
||||
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
@@ -378,14 +287,17 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
return matchesContactQuery(contact, _searchQuery);
|
||||
}).toList();
|
||||
|
||||
if (_typeFilter != ContactTypeFilter.all) {
|
||||
filtered = filtered.where(_matchesTypeFilter).toList();
|
||||
}
|
||||
|
||||
if (_showUnreadOnly) {
|
||||
filtered = filtered.where((contact) {
|
||||
return connector.getUnreadCountForContact(contact) > 0;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
final sortOption = _forceLastSeenSort ? ContactSortOption.lastSeen : _sortOption;
|
||||
switch (sortOption) {
|
||||
switch (_sortOption) {
|
||||
case ContactSortOption.lastSeen:
|
||||
filtered.sort((a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)));
|
||||
break;
|
||||
@@ -401,18 +313,24 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
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;
|
||||
}
|
||||
|
||||
bool _matchesTypeFilter(Contact contact) {
|
||||
switch (_typeFilter) {
|
||||
case ContactTypeFilter.all:
|
||||
return true;
|
||||
case ContactTypeFilter.users:
|
||||
return contact.type == advTypeChat;
|
||||
case ContactTypeFilter.repeaters:
|
||||
return contact.type == advTypeRepeater;
|
||||
case ContactTypeFilter.rooms:
|
||||
return contact.type == advTypeRoom;
|
||||
}
|
||||
}
|
||||
|
||||
DateTime _resolveLastSeen(Contact contact) {
|
||||
if (contact.type != advTypeChat) return contact.lastSeen;
|
||||
return contact.lastMessageAt.isAfter(contact.lastSeen)
|
||||
|
||||
@@ -39,10 +39,17 @@ class _DeviceScreenState extends State<DeviceScreen>
|
||||
canPop: false,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: _buildBatteryIndicator(connector, context),
|
||||
titleSpacing: 16,
|
||||
centerTitle: false,
|
||||
title: _buildAppBarTitle(connector, theme),
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bluetooth_disabled),
|
||||
tooltip: 'Disconnect',
|
||||
onPressed: () => _disconnect(context, connector),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.tune),
|
||||
tooltip: 'Settings',
|
||||
@@ -53,11 +60,6 @@ class _DeviceScreenState extends State<DeviceScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bluetooth_disabled),
|
||||
tooltip: 'Disconnect',
|
||||
onPressed: () => _disconnect(context, connector),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../services/map_marker_service.dart';
|
||||
import '../services/map_tile_cache_service.dart';
|
||||
import '../utils/contact_search.dart';
|
||||
import '../utils/route_transitions.dart';
|
||||
import '../widgets/battery_indicator.dart';
|
||||
import '../widgets/quick_switch_bar.dart';
|
||||
import 'channels_screen.dart';
|
||||
import 'chat_screen.dart';
|
||||
@@ -136,10 +137,16 @@ class _MapScreenState extends State<MapScreen> {
|
||||
canPop: allowBack,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: BatteryIndicator(connector: connector),
|
||||
title: const Text('Node Map'),
|
||||
centerTitle: true,
|
||||
automaticallyImplyLeading: !widget.hideBackButton && allowBack,
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bluetooth_disabled),
|
||||
tooltip: 'Disconnect',
|
||||
onPressed: () => _disconnect(context, connector),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.tune),
|
||||
tooltip: 'Settings',
|
||||
@@ -148,11 +155,6 @@ class _MapScreenState extends State<MapScreen> {
|
||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bluetooth_disabled),
|
||||
tooltip: 'Disconnect',
|
||||
onPressed: () => _disconnect(context, connector),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: !hasMapContent
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../widgets/debug_frame_viewer.dart';
|
||||
import '../services/repeater_command_service.dart';
|
||||
import '../widgets/path_management_dialog.dart';
|
||||
|
||||
class RepeaterCliScreen extends StatefulWidget {
|
||||
final Contact repeater;
|
||||
@@ -75,6 +76,13 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||
return connector.contacts.firstWhere(
|
||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||
orElse: () => widget.repeater,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTextMessageResponse(Uint8List frame) {
|
||||
final parsed = parseContactMessageText(frame);
|
||||
if (parsed == null) return;
|
||||
@@ -117,9 +125,12 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
// Send CLI command to repeater with retry
|
||||
try {
|
||||
if (_commandService != null) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final response = await _commandService!.sendCommand(
|
||||
widget.repeater,
|
||||
repeater,
|
||||
command,
|
||||
retries: 1,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
@@ -204,6 +215,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final isFloodMode = repeater.pathOverride == -1;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
@@ -212,13 +227,61 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
children: [
|
||||
const Text('Repeater CLI'),
|
||||
Text(
|
||||
widget.repeater.name,
|
||||
repeater.name,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||
tooltip: 'Routing mode',
|
||||
onSelected: (mode) async {
|
||||
if (mode == 'flood') {
|
||||
await connector.setPathOverride(repeater, pathLen: -1);
|
||||
} else {
|
||||
await connector.setPathOverride(repeater, pathLen: null);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'auto',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Auto (use saved path)',
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'flood',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Force Flood Mode',
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.timeline),
|
||||
tooltip: 'Path management',
|
||||
onPressed: () => PathManagementDialog.show(context, contact: repeater),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bug_report),
|
||||
tooltip: 'Debug Next Command',
|
||||
|
||||
@@ -33,11 +33,9 @@ class RepeaterHubScreen extends StatelessWidget {
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
children: [
|
||||
// Repeater info card
|
||||
Card(
|
||||
child: Padding(
|
||||
@@ -142,8 +140,7 @@ class RepeaterHubScreen extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../models/contact.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../services/repeater_command_service.dart';
|
||||
import '../widgets/path_management_dialog.dart';
|
||||
|
||||
class RepeaterSettingsScreen extends StatefulWidget {
|
||||
final Contact repeater;
|
||||
@@ -121,6 +122,13 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
_commandService?.handleResponse(widget.repeater, parsed.text);
|
||||
}
|
||||
|
||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||
return connector.contacts.firstWhere(
|
||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||
orElse: () => widget.repeater,
|
||||
);
|
||||
}
|
||||
|
||||
bool _matchesRepeaterPrefix(Uint8List prefix) {
|
||||
final target = widget.repeater.publicKey;
|
||||
if (target.length < 6 || prefix.length < 6) return false;
|
||||
@@ -326,9 +334,15 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
});
|
||||
|
||||
var successCount = 0;
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final repeater = _resolveRepeater(connector);
|
||||
for (final command in commands) {
|
||||
try {
|
||||
final response = await _commandService!.sendCommand(widget.repeater, command);
|
||||
final response = await _commandService!.sendCommand(
|
||||
repeater,
|
||||
command,
|
||||
retries: 1,
|
||||
);
|
||||
_applySettingResponse(command, response);
|
||||
successCount += 1;
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
@@ -422,12 +436,14 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
|
||||
Future<void> _saveSettings() async {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final repeater = _resolveRepeater(connector);
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final selection = await connector.preparePathForContactSend(repeater);
|
||||
final commands = <String>[];
|
||||
|
||||
// Build set commands for each setting
|
||||
@@ -470,7 +486,18 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
|
||||
// Send all commands
|
||||
for (final command in commands) {
|
||||
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command);
|
||||
final timestampSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
connector.trackRepeaterAck(
|
||||
contact: repeater,
|
||||
selection: selection,
|
||||
text: command,
|
||||
timestampSeconds: timestampSeconds,
|
||||
);
|
||||
final frame = buildSendCliCommandFrame(
|
||||
repeater.publicKey,
|
||||
command,
|
||||
timestampSeconds: timestampSeconds,
|
||||
);
|
||||
await connector.sendFrame(frame);
|
||||
await Future.delayed(const Duration(milliseconds: 200)); // Delay between commands
|
||||
}
|
||||
@@ -544,6 +571,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final isFloodMode = repeater.pathOverride == -1;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
@@ -552,13 +583,64 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
children: [
|
||||
const Text('Repeater Settings'),
|
||||
Text(
|
||||
widget.repeater.name,
|
||||
repeater.name,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||
tooltip: 'Routing mode',
|
||||
onSelected: (mode) async {
|
||||
if (mode == 'flood') {
|
||||
await connector.setPathOverride(repeater, pathLen: -1);
|
||||
} else {
|
||||
await connector.setPathOverride(repeater, pathLen: null);
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'auto',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Auto (use saved path)',
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'flood',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Force Flood Mode',
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.timeline),
|
||||
tooltip: 'Path management',
|
||||
onPressed: () => PathManagementDialog.show(context, contact: repeater),
|
||||
),
|
||||
if (_hasChanges)
|
||||
TextButton.icon(
|
||||
onPressed: _isLoading ? null : _saveSettings,
|
||||
@@ -995,6 +1077,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
|
||||
Future<void> _sendDangerCommand(String command) async {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final repeater = _resolveRepeater(connector);
|
||||
|
||||
if (command == 'erase') {
|
||||
if (mounted) {
|
||||
@@ -1006,7 +1089,19 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
}
|
||||
|
||||
try {
|
||||
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command);
|
||||
final selection = await connector.preparePathForContactSend(repeater);
|
||||
final timestampSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
connector.trackRepeaterAck(
|
||||
contact: repeater,
|
||||
selection: selection,
|
||||
text: command,
|
||||
timestampSeconds: timestampSeconds,
|
||||
);
|
||||
final frame = buildSendCliCommandFrame(
|
||||
repeater.publicKey,
|
||||
command,
|
||||
timestampSeconds: timestampSeconds,
|
||||
);
|
||||
await connector.sendFrame(frame);
|
||||
|
||||
if (mounted) {
|
||||
|
||||
@@ -4,9 +4,11 @@ import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/path_selection.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../services/repeater_command_service.dart';
|
||||
import '../widgets/path_management_dialog.dart';
|
||||
|
||||
class RepeaterStatusScreen extends StatefulWidget {
|
||||
final Contact repeater;
|
||||
@@ -23,6 +25,10 @@ class RepeaterStatusScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
static const int _statusPayloadOffset = 8;
|
||||
static const int _statusStatsSize = 52;
|
||||
static const int _statusResponseBytes = _statusPayloadOffset + _statusStatsSize;
|
||||
|
||||
bool _isLoading = false;
|
||||
StreamSubscription<Uint8List>? _frameSubscription;
|
||||
RepeaterCommandService? _commandService;
|
||||
@@ -45,6 +51,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
int? _directRx;
|
||||
int? _dupFlood;
|
||||
int? _dupDirect;
|
||||
PathSelection? _pendingStatusSelection;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -80,6 +87,13 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||
return connector.contacts.firstWhere(
|
||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||
orElse: () => widget.repeater,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTextMessageResponse(Uint8List frame) {
|
||||
final parsed = parseContactMessageText(frame);
|
||||
if (parsed == null) return;
|
||||
@@ -90,6 +104,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
|
||||
// Parse status responses
|
||||
_parseStatusResponse(parsed.text);
|
||||
_recordStatusResult(true);
|
||||
}
|
||||
|
||||
void _handleStatusResponse(Uint8List frame) {
|
||||
@@ -97,11 +112,13 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
final prefix = frame.sublist(2, 8);
|
||||
if (!_matchesRepeaterPrefix(prefix)) return;
|
||||
|
||||
const payloadOffset = 8;
|
||||
const statsSize = 52;
|
||||
if (frame.length < payloadOffset + statsSize) return;
|
||||
if (frame.length < _statusResponseBytes) return;
|
||||
|
||||
final data = ByteData.sublistView(frame, payloadOffset, payloadOffset + statsSize);
|
||||
final data = ByteData.sublistView(
|
||||
frame,
|
||||
_statusPayloadOffset,
|
||||
_statusResponseBytes,
|
||||
);
|
||||
int offset = 0;
|
||||
|
||||
final batteryMv = data.getUint16(offset, Endian.little);
|
||||
@@ -160,6 +177,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
_dupDirect = directDups;
|
||||
_dupFlood = floodDups;
|
||||
});
|
||||
_recordStatusResult(true);
|
||||
}
|
||||
|
||||
bool _matchesRepeaterPrefix(Uint8List prefix) {
|
||||
@@ -213,6 +231,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_statusRequestedAt = DateTime.now();
|
||||
_pendingStatusSelection = null;
|
||||
_batteryMv = null;
|
||||
_uptimeSecs = null;
|
||||
_queueLen = null;
|
||||
@@ -234,11 +253,22 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
|
||||
try {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final frame = buildSendStatusRequestFrame(widget.repeater.publicKey);
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final selection = await connector.preparePathForContactSend(repeater);
|
||||
_pendingStatusSelection = selection;
|
||||
final frame = buildSendStatusRequestFrame(repeater.publicKey);
|
||||
await connector.sendFrame(frame);
|
||||
|
||||
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
||||
final messageBytes = frame.length >= _statusResponseBytes
|
||||
? frame.length
|
||||
: _statusResponseBytes;
|
||||
final timeoutMs = connector.calculateTimeout(
|
||||
pathLength: pathLengthValue,
|
||||
messageBytes: messageBytes,
|
||||
);
|
||||
_statusTimeout?.cancel();
|
||||
_statusTimeout = Timer(const Duration(seconds: 12), () {
|
||||
_statusTimeout = Timer(Duration(milliseconds: timeoutMs), () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
@@ -249,6 +279,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
_recordStatusResult(false);
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
@@ -263,11 +294,25 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
_recordStatusResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
void _recordStatusResult(bool success) {
|
||||
final selection = _pendingStatusSelection;
|
||||
if (selection == null) return;
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final repeater = _resolveRepeater(connector);
|
||||
connector.recordRepeaterPathResult(repeater, selection, success, null);
|
||||
_pendingStatusSelection = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final isFloodMode = repeater.pathOverride == -1;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
@@ -276,13 +321,61 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
children: [
|
||||
const Text('Repeater Status'),
|
||||
Text(
|
||||
widget.repeater.name,
|
||||
repeater.name,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||
tooltip: 'Routing mode',
|
||||
onSelected: (mode) async {
|
||||
if (mode == 'flood') {
|
||||
await connector.setPathOverride(repeater, pathLen: -1);
|
||||
} else {
|
||||
await connector.setPathOverride(repeater, pathLen: null);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'auto',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Auto (use saved path)',
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'flood',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Force Flood Mode',
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.timeline),
|
||||
tooltip: 'Path management',
|
||||
onPressed: () => PathManagementDialog.show(context, contact: repeater),
|
||||
),
|
||||
IconButton(
|
||||
icon: _isLoading
|
||||
? const SizedBox(
|
||||
|
||||
@@ -5,11 +5,19 @@ import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../models/radio_settings.dart';
|
||||
import 'app_settings_screen.dart';
|
||||
import 'app_debug_log_screen.dart';
|
||||
import 'ble_debug_log_screen.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _showBatteryVoltage = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -58,6 +66,7 @@ class SettingsScreen extends StatelessWidget {
|
||||
_buildInfoRow('Name', connector.deviceDisplayName),
|
||||
_buildInfoRow('ID', connector.deviceIdLabel),
|
||||
_buildInfoRow('Status', connector.isConnected ? 'Connected' : 'Disconnected'),
|
||||
_buildBatteryInfoRow(connector),
|
||||
if (connector.selfName != null)
|
||||
_buildInfoRow('Node Name', connector.selfName!),
|
||||
if (connector.selfPublicKey != null)
|
||||
@@ -70,6 +79,53 @@ class SettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBatteryInfoRow(MeshCoreConnector connector) {
|
||||
final percent = connector.batteryPercent;
|
||||
final millivolts = connector.batteryMillivolts;
|
||||
|
||||
// figure out display value
|
||||
final String displayValue;
|
||||
if (millivolts == null) {
|
||||
displayValue = '—';
|
||||
} else if (_showBatteryVoltage) {
|
||||
displayValue = '${(millivolts / 1000.0).toStringAsFixed(2)} V';
|
||||
} else {
|
||||
displayValue = percent != null ? '$percent%' : '—';
|
||||
}
|
||||
|
||||
final IconData icon;
|
||||
final Color? iconColor;
|
||||
final Color? valueColor;
|
||||
|
||||
if (percent == null) {
|
||||
icon = Icons.battery_unknown;
|
||||
iconColor = Colors.grey;
|
||||
valueColor = null;
|
||||
} else if (percent <= 15) {
|
||||
icon = Icons.battery_alert;
|
||||
iconColor = Colors.orange;
|
||||
valueColor = Colors.orange;
|
||||
} else {
|
||||
icon = Icons.battery_full;
|
||||
iconColor = null;
|
||||
valueColor = null;
|
||||
}
|
||||
|
||||
return _buildInfoRow(
|
||||
'Battery',
|
||||
displayValue,
|
||||
leading: Icon(icon, size: 18, color: iconColor),
|
||||
valueColor: valueColor,
|
||||
onTap: millivolts != null
|
||||
? () {
|
||||
setState(() {
|
||||
_showBatteryVoltage = !_showBatteryVoltage;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppSettingsCard(BuildContext context) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
@@ -192,38 +248,89 @@ class SettingsScreen extends StatelessWidget {
|
||||
|
||||
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()),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
'Debug',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.bluetooth_outlined),
|
||||
title: const Text('BLE Debug Log'),
|
||||
subtitle: const Text('BLE commands, responses, and raw data'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const BleDebugLogScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.code_outlined),
|
||||
title: const Text('App Debug Log'),
|
||||
subtitle: const Text('Application debug messages'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const AppDebugLogScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(String label, String value) {
|
||||
return Padding(
|
||||
Widget _buildInfoRow(
|
||||
String label,
|
||||
String value, {
|
||||
Widget? leading,
|
||||
Color? valueColor,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
final row = Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: Colors.grey[600])),
|
||||
Row(
|
||||
children: [
|
||||
if (leading != null) ...[
|
||||
leading,
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(label, style: TextStyle(color: Colors.grey[600])),
|
||||
],
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: valueColor,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: row,
|
||||
);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
void _editNodeName(BuildContext context, MeshCoreConnector connector) {
|
||||
|
||||
Reference in New Issue
Block a user