upgraded flutter and other fixes

This commit is contained in:
zach
2025-12-31 22:19:48 -07:00
parent be97e5c7fc
commit 44be6cd5e7
24 changed files with 2082 additions and 442 deletions
+16 -7
View File
@@ -1,5 +1,4 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -42,6 +41,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.read<MeshCoreConnector>().setActiveChannel(widget.channel.index);
// Scroll to bottom when opening channel chat
if (_scrollController.hasClients) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
});
}
@@ -352,12 +356,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Widget contentPreview;
if (gifId != null) {
contentPreview = Row(
children: [
Icon(Icons.gif_box, size: 14, color: previewTextColor),
const SizedBox(width: 4),
Text('GIF', style: TextStyle(fontSize: 12, color: previewTextColor)),
],
contentPreview = ClipRRect(
borderRadius: BorderRadius.circular(4),
child: GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: colorScheme.surfaceContainerHighest,
fallbackTextColor: previewTextColor,
width: 120,
height: 80,
),
);
} else if (poi != null) {
contentPreview = Row(
@@ -843,6 +850,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
void _sendReaction(ChannelMessage message, String emoji) {
final connector = context.read<MeshCoreConnector>();
// Send reaction with full messageId to find target, but parser will extract
// lightweight reactionKey (timestamp_senderPrefix) for deduplication
final reactionText = 'r:${message.messageId}:$emoji';
connector.sendChannelMessage(widget.channel, reactionText);
}
+37 -39
View File
@@ -78,48 +78,46 @@ class _ChannelsScreenState extends State<ChannelsScreen>
),
],
),
body: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
if (connector.isLoadingChannels) {
return const Center(child: CircularProgressIndicator());
}
body: () {
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'),
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(),
),
);
}
return ReorderableListView.builder(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 88),
buildDefaultDragHandles: false,
itemCount: channels.length,
onReorder: (oldIndex, newIndex) {
if (newIndex > oldIndex) newIndex -= 1;
final reordered = List<Channel>.from(channels);
final item = reordered.removeAt(oldIndex);
reordered.insert(newIndex, item);
unawaited(
connector.setChannelOrder(
reordered.map((c) => c.index).toList(),
),
);
},
itemBuilder: (context, index) {
final channel = channels[index];
return _buildChannelTile(context, connector, channel, index);
},
);
},
),
},
itemBuilder: (context, index) {
final channel = channels[index];
return _buildChannelTile(context, connector, channel, index);
},
);
}(),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddChannelDialog(context),
child: const Icon(Icons.add),
@@ -144,7 +142,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
final unreadCount = connector.getUnreadCountForChannel(channel);
return Card(
key: ValueKey('channel_${channel.index}'),
margin: const EdgeInsets.symmetric(vertical: 4),
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
dense: true,
minVerticalPadding: 0,
+94 -81
View File
@@ -32,7 +32,6 @@ class ChatScreen extends StatefulWidget {
class _ChatScreenState extends State<ChatScreen> {
final _textController = TextEditingController();
final _scrollController = ScrollController();
bool _clearPath = false;
@override
void initState() {
@@ -40,6 +39,11 @@ class _ChatScreenState extends State<ChatScreen> {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.read<MeshCoreConnector>().setActiveContact(widget.contact.publicKeyHex);
// Scroll to bottom when opening chat
if (_scrollController.hasClients) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
});
}
@@ -60,75 +64,86 @@ class _ChatScreenState extends State<ChatScreen> {
final contact = _resolveContact(connector);
final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex);
final unreadLabel = 'Unread: $unreadCount';
final pathLabel = _clearPath ? 'Flood (forced)' : _currentPathLabel(contact);
final canShowPathDetails = !_clearPath && contact.path.isNotEmpty;
final pathLabel = _currentPathLabel(contact);
// Show path details if we have path data (from device or override)
final hasPathData = contact.path.isNotEmpty || contact.pathOverrideBytes != null;
final effectivePath = contact.pathOverrideBytes ?? contact.path;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(contact.name),
if (canShowPathDetails)
GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: () => _showFullPathDialog(context, contact.path),
child: Text(
'$pathLabel$unreadLabel',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal),
),
)
else
Text(
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: hasPathData ? () => _showFullPathDialog(context, effectivePath) : null,
child: Text(
'$pathLabel$unreadLabel',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.normal,
decoration: hasPathData ? TextDecoration.underline : null,
decorationStyle: TextDecorationStyle.dotted,
),
),
),
],
);
},
),
centerTitle: false,
actions: [
PopupMenuButton<String>(
icon: Icon(_clearPath ? Icons.waves : Icons.route),
tooltip: 'Routing mode',
onSelected: (mode) {
setState(() {
_clearPath = (mode == 'flood');
});
Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final contact = _resolveContact(connector);
final isFloodMode = contact.pathOverride == -1;
return PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode',
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(contact, pathLen: -1);
} else {
await connector.setPathOverride(contact, 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,
),
),
],
),
),
],
);
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(Icons.auto_mode, size: 20, color: !_clearPath ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Auto (use saved path)',
style: TextStyle(
fontWeight: !_clearPath ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(Icons.waves, size: 20, color: _clearPath ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Force Flood Mode',
style: TextStyle(
fontWeight: _clearPath ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
],
),
IconButton(
icon: const Icon(Icons.timeline),
@@ -304,7 +319,6 @@ class _ChatScreenState extends State<ChatScreen> {
connector.sendMessage(
widget.contact,
text,
clearPath: _clearPath,
);
_textController.clear();
@@ -416,23 +430,14 @@ class _ChatScreenState extends State<ChatScreen> {
final pathBytes = Uint8List.fromList(path.pathBytes);
final pathLength = path.pathBytes.length;
await connector.setContactPath(
// Set the path override to persist user's choice
await connector.setPathOverride(
widget.contact,
pathBytes,
pathLength,
);
// Update contact in memory directly for immediate UI feedback
connector.updateContactInMemory(
widget.contact.publicKeyHex,
pathLen: pathLength,
pathBytes: pathBytes,
pathLength: pathLength,
);
if (!context.mounted) return;
setState(() {
_clearPath = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Using ${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'} path'),
@@ -499,10 +504,9 @@ class _ChatScreenState extends State<ChatScreen> {
),
title: const Text('Force Flood Mode', style: TextStyle(fontSize: 14)),
subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)),
onTap: () {
setState(() {
_clearPath = true;
});
onTap: () async {
await connector.setPathOverride(widget.contact, pathLen: -1);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Flood mode enabled. Toggle back via routing icon in app bar.'),
@@ -573,9 +577,16 @@ class _ChatScreenState extends State<ChatScreen> {
}
String _currentPathLabel(Contact contact) {
// Check if user has set a path override
if (contact.pathOverride != null) {
if (contact.pathOverride! < 0) return 'Flood (forced)';
if (contact.pathOverride == 0) return 'Direct (forced)';
return '${contact.pathOverride} hops (forced)';
}
// Use device's path
if (contact.pathLength < 0) return 'Flood (auto)';
if (contact.pathLength == 0) return 'Direct';
if (contact.pathIdList.isNotEmpty) return contact.pathIdList;
return '${contact.pathLength} hops';
}
@@ -604,7 +615,7 @@ class _ChatScreenState extends State<ChatScreen> {
'Location',
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
),
_buildInfoRow('Public Key', contact.publicKeyHex.substring(0, 16) + '...'),
_buildInfoRow('Public Key', '${contact.publicKeyHex.substring(0, 16)}...'),
const Divider(),
SwitchListTile(
contentPadding: EdgeInsets.zero,
@@ -1125,12 +1136,10 @@ class _ChatScreenState extends State<ChatScreen> {
void _retryMessage(Message message) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Retry with clearPath if the message has no path or pathLength is -1 (indicating flood was used)
final shouldClearPath = message.pathLength != null && message.pathLength! < 0;
// Retry using the contact's current path override setting
connector.sendMessage(
widget.contact,
message.text,
clearPath: shouldClearPath,
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Retrying message')),
@@ -1151,7 +1160,11 @@ class _ChatScreenState extends State<ChatScreen> {
void _sendReaction(Message message, String emoji) {
final connector = context.read<MeshCoreConnector>();
final reactionText = 'r:${message.messageId}:$emoji';
// Send reaction with messageId if available, otherwise use lightweight format
// Parser will extract reactionKey (timestamp_senderPrefix) for deduplication
final messageId = message.messageId ??
'${message.timestamp.millisecondsSinceEpoch}_${message.senderKeyHex.substring(0, 8)}';
final reactionText = 'r:$messageId:$emoji';
connector.sendMessage(widget.contact, reactionText);
}
}
@@ -1176,7 +1189,6 @@ class _MessageBubble extends StatelessWidget {
final gifId = _parseGifId(message.text);
final poi = _parsePoiMessage(message.text);
final isFailed = message.status == MessageStatus.failed;
final attempts = message.retryCount + 1;
final bubbleColor = isFailed
? colorScheme.errorContainer
: (isOutgoing ? colorScheme.primary : colorScheme.surfaceContainerHighest);
@@ -1240,13 +1252,14 @@ class _MessageBubble extends StatelessWidget {
color: textColor,
),
),
if (isOutgoing) ...[
if (isOutgoing && message.retryCount > 0) ...[
const SizedBox(height: 4),
Text(
'Attempts: $attempts',
'Retry ${message.retryCount}/4',
style: TextStyle(
fontSize: 10,
color: metaColor,
fontWeight: FontWeight.w500,
),
),
],
+2 -2
View File
@@ -682,7 +682,7 @@ class _ContactsScreenState extends State<ContactsScreen>
return;
}
final exists = _groups.any((g) {
if (isEditing && g.name == group!.name) return false;
if (isEditing && g.name == group.name) return false;
return g.name.toLowerCase() == name.toLowerCase();
});
if (exists) {
@@ -693,7 +693,7 @@ class _ContactsScreenState extends State<ContactsScreen>
}
setState(() {
if (isEditing) {
final index = _groups.indexWhere((g) => g.name == group!.name);
final index = _groups.indexWhere((g) => g.name == group.name);
if (index != -1) {
_groups[index] = ContactGroup(
name: name,
+1 -1
View File
@@ -123,7 +123,7 @@ class _DeviceScreenState extends State<DeviceScreen>
return Card(
elevation: 0,
color: colorScheme.surfaceVariant,
color: colorScheme.surfaceContainerHighest,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
+4 -3
View File
@@ -142,7 +142,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
),
);
if (confirmed != true) return;
if (confirmed != true || !mounted) return;
final cacheService = context.read<MapTileCacheService>();
setState(() {
_isDownloading = true;
@@ -150,7 +152,6 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
_failedTiles = 0;
});
final cacheService = context.read<MapTileCacheService>();
final result = await cacheService.downloadRegion(
bounds: bounds,
minZoom: _minZoom,
@@ -198,7 +199,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
],
),
);
if (confirmed != true) return;
if (confirmed != true || !mounted) return;
final cacheService = context.read<MapTileCacheService>();
await cacheService.clearCache();
+4 -1
View File
@@ -785,10 +785,13 @@ class _MapScreenState extends State<MapScreen> {
}
final label = await _promptForLabel(context, defaultLabel);
if (label == null) return;
if (label == null || !mounted) return;
final markerText = _formatMarkerMessage(position, label, flags);
if (!mounted) return;
await _showRecipientSheet(
// ignore: use_build_context_synchronously
context: context,
connector: connector,
markerText: markerText,
+1 -1
View File
@@ -767,7 +767,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 8),
color: colorScheme.surfaceVariant,
color: colorScheme.surfaceContainerHighest,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: colorScheme.outlineVariant),
+5 -4
View File
@@ -4,7 +4,6 @@ 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';
@@ -63,6 +62,8 @@ class SettingsScreen extends StatelessWidget {
_buildInfoRow('Node Name', connector.selfName!),
if (connector.selfPublicKey != null)
_buildInfoRow('Public Key', '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...'),
_buildInfoRow('Contacts Count', '${connector.contacts.length}'),
_buildInfoRow('Channel Count', '${connector.channels.length}'),
],
),
),
@@ -619,7 +620,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
),
const SizedBox(height: 16),
DropdownButtonFormField<LoRaBandwidth>(
value: _bandwidth,
initialValue: _bandwidth,
decoration: const InputDecoration(
labelText: 'Bandwidth',
border: OutlineInputBorder(),
@@ -636,7 +637,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
),
const SizedBox(height: 16),
DropdownButtonFormField<LoRaSpreadingFactor>(
value: _spreadingFactor,
initialValue: _spreadingFactor,
decoration: const InputDecoration(
labelText: 'Spreading Factor',
border: OutlineInputBorder(),
@@ -653,7 +654,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
),
const SizedBox(height: 16),
DropdownButtonFormField<LoRaCodingRate>(
value: _codingRate,
initialValue: _codingRate,
decoration: const InputDecoration(
labelText: 'Coding Rate',
border: OutlineInputBorder(),