From 35e866abfbf25bf8d0d8ac9a28e7234e72cde501 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Wed, 7 Jan 2026 23:28:49 -0800 Subject: [PATCH 1/6] Add login for room servers --- lib/screens/contacts_screen.dart | 32 +++ lib/widgets/room_login_dialog.dart | 416 +++++++++++++++++++++++++++++ 2 files changed, 448 insertions(+) create mode 100644 lib/widgets/room_login_dialog.dart diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index e16da6b..eed38df 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -18,6 +18,7 @@ import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; import '../widgets/quick_switch_bar.dart'; import '../widgets/repeater_login_dialog.dart'; +import '../widgets/room_login_dialog.dart'; import '../widgets/unread_badge.dart'; import 'channels_screen.dart'; import 'chat_screen.dart'; @@ -384,6 +385,8 @@ class _ContactsScreenState extends State // Check if this is a repeater if (contact.type == advTypeRepeater) { _showRepeaterLogin(context, contact); + } else if (contact.type == advTypeRoom) { + _showRoomLogin(context, contact); } else { context.read().markContactRead(contact.publicKeyHex); Navigator.push( @@ -436,6 +439,25 @@ class _ContactsScreenState extends State ); } + void _showRoomLogin(BuildContext context, Contact room) { + showDialog( + context: context, + builder: (context) => RoomLoginDialog( + room: room, + onLogin: (password) { + // Navigate to chat screen after successful login + context.read().markContactRead(room.publicKeyHex); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen(contact: room), + ), + ); + }, + ), + ); + } + void _showGroupOptions(BuildContext context, ContactGroup group, List contacts) { final members = _resolveGroupContacts(group, contacts); showModalBottomSheet( @@ -642,6 +664,7 @@ class _ContactsScreenState extends State Contact contact, ) { final isRepeater = contact.type == advTypeRepeater; + final isRoom = contact.type == advTypeRoom; showModalBottomSheet( context: context, @@ -658,6 +681,15 @@ class _ContactsScreenState extends State _showRepeaterLogin(context, contact); }, ) + else if(isRoom) + ListTile( + leading: const Icon(Icons.room, color: Colors.blue), + title: const Text('Room Login'), + onTap: () { + Navigator.pop(context); + _showRoomLogin(context, contact); + }, + ) else ListTile( leading: const Icon(Icons.chat), diff --git a/lib/widgets/room_login_dialog.dart b/lib/widgets/room_login_dialog.dart new file mode 100644 index 0000000..dc4592d --- /dev/null +++ b/lib/widgets/room_login_dialog.dart @@ -0,0 +1,416 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:provider/provider.dart'; +import '../models/contact.dart'; +import '../services/storage_service.dart'; +import '../connector/meshcore_connector.dart'; +import '../connector/meshcore_protocol.dart'; +import '../utils/app_logger.dart'; +import 'path_management_dialog.dart'; + +class RoomLoginDialog extends StatefulWidget { + final Contact room; + final Function(String password) onLogin; + + const RoomLoginDialog({ + super.key, + required this.room, + required this.onLogin, + }); + + @override + State createState() => _RoomLoginDialogState(); +} + +class _RoomLoginDialogState extends State { + final TextEditingController _passwordController = TextEditingController(); + final StorageService _storage = StorageService(); + bool _savePassword = false; + bool _isLoading = true; + bool _obscurePassword = true; + late MeshCoreConnector _connector; + int _currentAttempt = 0; + static const int _maxAttempts = 5; + + @override + void initState() { + super.initState(); + _connector = Provider.of(context, listen: false); + _loadSavedPassword(); + } + + Future _loadSavedPassword() async { + final savedPassword = + await _storage.getRepeaterPassword(widget.room.publicKeyHex); + if (savedPassword != null) { + setState(() { + _passwordController.text = savedPassword; + _savePassword = true; + _isLoading = false; + }); + } else { + setState(() { + _isLoading = false; + }); + } + } + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } + + bool _isLoggingIn = false; + + Contact _resolveRepeater(MeshCoreConnector connector) { + return connector.contacts.firstWhere( + (c) => c.publicKeyHex == widget.room.publicKeyHex, + orElse: () => widget.room, + ); + } + + Future _handleLogin() async { + if (_isLoggingIn) return; + + setState(() { + _isLoggingIn = true; + _currentAttempt = 0; + }); + + try { + final password = _passwordController.text; + final room = _resolveRepeater(_connector); + appLogger.info( + 'Login started for ${room.name} (${room.publicKeyHex})', + tag: 'RoomLogin', + ); + final selection = await _connector.preparePathForContactSend(room); + final loginFrame = buildSendLoginFrame(room.publicKey, password); + final pathLengthValue = selection.useFlood ? -1 : selection.hopCount; + final timeoutMs = _connector.calculateTimeout( + pathLength: pathLengthValue, + messageBytes: loginFrame.length, + ); + final timeoutSeconds = (timeoutMs / 1000).ceil(); + final timeout = Duration(milliseconds: timeoutMs); + final selectionLabel = + selection.useFlood ? 'flood' : '${selection.hopCount} hops'; + appLogger.info( + 'Login routing: $selectionLabel', + tag: 'RoomLogin', + ); + bool? loginResult; + for (int attempt = 0; attempt < _maxAttempts; attempt++) { + if (!mounted) return; + setState(() { + _currentAttempt = attempt + 1; + }); + + appLogger.info( + 'Sending login attempt ${attempt + 1}/$_maxAttempts', + tag: 'RoomLogin', + ); + await _connector.sendFrame( + loginFrame, + ); + + loginResult = await _awaitLoginResponse(timeout); + if (loginResult == true) { + appLogger.info( + 'Login succeeded for ${room.name}', + tag: 'RoomLogin', + ); + break; + } + if (loginResult == false) { + appLogger.warn( + 'Login failed for ${room.name}', + tag: 'RoomLogin', + ); + throw Exception('Wrong password or node is unreachable'); + } + appLogger.warn( + 'Login attempt ${attempt + 1} timed out after ${timeoutSeconds}s', + tag: 'RoomLogin', + ); + } + + if (loginResult == null) { + appLogger.warn( + 'Login timed out for ${room.name}', + tag: 'RoomLogin', + ); + } + + if (loginResult == true) { + _connector.recordRepeaterPathResult(room, selection, true, null); + } else { + _connector.recordRepeaterPathResult(room, selection, false, null); + } + + if (loginResult != true) { + throw Exception('Wrong password or node is unreachable'); + } + + // If we got a response, login succeeded + // Save password if requested + if (_savePassword) { + await _storage.saveRepeaterPassword( + widget.room.publicKeyHex, password); + } else { + // Remove saved password if user unchecked the box + await _storage.removeRepeaterPassword(widget.room.publicKeyHex); + } + + if (mounted) { + Navigator.pop(context, password); + Future.microtask(() => widget.onLogin(password)); + } + } catch (e) { + final room = _resolveRepeater(_connector); + appLogger.warn( + 'Login error for ${room.name}: $e', + tag: 'RoomLogin', + ); + if (mounted) { + setState(() { + _isLoggingIn = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Login failed: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _awaitLoginResponse(Duration timeout) async { + final completer = Completer(); + Timer? timer; + StreamSubscription? subscription; + final targetPrefix = widget.room.publicKey.sublist(0, 6); + + subscription = _connector.receivedFrames.listen((frame) { + if (frame.isEmpty) return; + final code = frame[0]; + if (code != pushCodeLoginSuccess && code != pushCodeLoginFail) return; + if (frame.length < 8) return; + final prefix = frame.sublist(2, 8); + if (!listEquals(prefix, targetPrefix)) return; + + completer.complete(code == pushCodeLoginSuccess); + subscription?.cancel(); + timer?.cancel(); + }); + + timer = Timer(timeout, () { + if (!completer.isCompleted) { + completer.complete(null); + subscription?.cancel(); + } + }); + + final result = await completer.future; + timer.cancel(); + await subscription.cancel(); + return result; + } + + @override + Widget build(BuildContext context) { + final connector = context.watch(); + final repeater = _resolveRepeater(connector); + final isFloodMode = repeater.pathOverride == -1; + return AlertDialog( + title: Row( + children: [ + const Icon(Icons.group, color: Colors.purple), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Room Login'), + Text( + repeater.name, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.normal, + color: Colors.grey[600], + ), + ), + ], + ), + ), + ], + ), + content: _isLoading + ? const Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: CircularProgressIndicator(), + ), + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Enter the room password to access settings and status.', + style: TextStyle(fontSize: 14), + ), + const SizedBox(height: 16), + TextField( + controller: _passwordController, + obscureText: _obscurePassword, + decoration: InputDecoration( + labelText: 'Password', + hintText: 'Enter password', + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.lock), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility + : Icons.visibility_off, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + ), + onSubmitted: (_) => _handleLogin(), + autofocus: _passwordController.text.isEmpty, + ), + const SizedBox(height: 12), + CheckboxListTile( + value: _savePassword, + onChanged: (value) { + setState(() { + _savePassword = value ?? false; + }); + }, + title: const Text( + 'Save password', + style: TextStyle(fontSize: 14), + ), + subtitle: const Text( + 'Password will be stored securely on this device', + style: TextStyle(fontSize: 12), + ), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + const Divider(), + Row( + children: [ + const Text( + 'Routing', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + ), + const Spacer(), + PopupMenuButton( + 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, + ), + ), + ], + ), + ), + ], + ), + ], + ), + const SizedBox(height: 4), + Text( + repeater.pathLabel, + style: const TextStyle(fontSize: 11, color: Colors.grey), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () => PathManagementDialog.show(context, contact: repeater), + icon: const Icon(Icons.timeline, size: 18), + label: const Text('Manage Paths'), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + if (_isLoggingIn) + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: null, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + const SizedBox(width: 12), + Text('Attempt $_currentAttempt/$_maxAttempts'), + ], + ), + ), + ) + else + FilledButton.icon( + onPressed: _isLoading ? null : _handleLogin, + icon: const Icon(Icons.login, size: 18), + label: const Text('Login'), + ), + ], + ); + } +} From fca810737d02f30703c4cdb47094d8560ea6714f Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Thu, 8 Jan 2026 12:58:27 -0800 Subject: [PATCH 2/6] Working on Parsing room server messages. --- lib/screens/contacts_screen.dart | 3 +- lib/screens/room_chat_screen.dart | 1236 +++++++++++++++++++++++++++++ 2 files changed, 1238 insertions(+), 1 deletion(-) create mode 100644 lib/screens/room_chat_screen.dart diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index eed38df..896101f 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -22,6 +22,7 @@ import '../widgets/room_login_dialog.dart'; import '../widgets/unread_badge.dart'; import 'channels_screen.dart'; import 'chat_screen.dart'; +import 'room_chat_screen.dart'; import 'map_screen.dart'; import 'repeater_hub_screen.dart'; import 'settings_screen.dart'; @@ -450,7 +451,7 @@ class _ContactsScreenState extends State Navigator.push( context, MaterialPageRoute( - builder: (context) => ChatScreen(contact: room), + builder: (context) => RoomChatScreen(contact: room), ), ); }, diff --git a/lib/screens/room_chat_screen.dart b/lib/screens/room_chat_screen.dart new file mode 100644 index 0000000..969a7e1 --- /dev/null +++ b/lib/screens/room_chat_screen.dart @@ -0,0 +1,1236 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +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'; + +import '../connector/meshcore_connector.dart'; +import '../connector/meshcore_protocol.dart'; +import '../helpers/utf8_length_limiter.dart'; +import '../models/channel_message.dart'; +import '../models/contact.dart'; +import '../models/message.dart'; +import '../services/path_history_service.dart'; +import 'channel_message_path_screen.dart'; +import 'map_screen.dart'; +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 RoomChatScreen extends StatefulWidget { + final Contact contact; + + const RoomChatScreen({super.key, required this.contact}); + + @override + State createState() => _RoomChatScreenState(); +} + +class _RoomChatScreenState extends State { + final _textController = TextEditingController(); + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + SchedulerBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().setActiveContact(widget.contact.publicKeyHex); + + // Scroll to bottom when opening chat use SchedulerBinding for next frame + if (_scrollController.hasClients) { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + } + }); + } + + @override + void dispose() { + context.read().setActiveContact(null); + _textController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Consumer2( + builder: (context, pathService, connector, _) { + final contact = _resolveContact(connector); + final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex); + final unreadLabel = 'Unread: $unreadCount'; + 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), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: hasPathData ? () => _showFullPathDialog(context, effectivePath) : null, + child: Text( + '$pathLabel • $unreadLabel', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.normal, + decoration: hasPathData ? TextDecoration.underline : null, + decorationStyle: TextDecorationStyle.dotted, + ), + ), + ), + ], + ); + }, + ), + centerTitle: false, + actions: [ + Consumer( + builder: (context, connector, _) { + final contact = _resolveContact(connector); + final isFloodMode = contact.pathOverride == -1; + + return PopupMenuButton( + 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, + ), + ), + ], + ), + ), + ], + ); + }, + ), + IconButton( + icon: const Icon(Icons.timeline), + tooltip: 'Path management', + onPressed: () => _showPathHistory(context), + ), + IconButton( + icon: const Icon(Icons.info_outline), + onPressed: () => _showContactInfo(context), + ), + ], + ), + body: Consumer( + builder: (context, connector, child) { + final messages = connector.getMessages(widget.contact); + return Column( + children: [ + Expanded( + child: messages.isEmpty + ? _buildEmptyState() + : _buildMessageList(messages, connector), + ), + _buildInputBar(connector), + ], + ); + }, + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + 'No messages yet', + style: TextStyle(fontSize: 16, color: Colors.grey[600]), + ), + const SizedBox(height: 8), + Text( + 'Send a message to ${widget.contact.name}', + style: TextStyle(fontSize: 14, color: Colors.grey[500]), + ), + ], + ), + ); + } + + Widget _buildMessageList(List messages, MeshCoreConnector connector) { + + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16), + itemCount: messages.length, + itemBuilder: (context, index) { + final message = messages[index]; + final contact = _resolveContactFrom4Bytes( + connector, + Uint8List.fromList(message.text.substring(0, 4.clamp(0, message.text.length)).codeUnits), + ); + + return _MessageBubble( + message: message, + senderName: contact.name, + onTap: () => _openMessagePath(message), + onLongPress: () => _showMessageActions(message), + ); + }, + ); + } + + Widget _buildInputBar(MeshCoreConnector connector) { + final maxBytes = maxContactMessageBytes(); + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colorScheme.surface, + border: Border( + top: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: SafeArea( + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.gif_box), + onPressed: () => _showGifPicker(context), + tooltip: 'Send GIF', + ), + Expanded( + child: ValueListenableBuilder( + valueListenable: _textController, + builder: (context, value, child) { + final gifId = _parseGifId(value.text); + if (gifId != null) { + return Row( + children: [ + Expanded( + child: GifMessage( + url: 'https://media.giphy.com/media/$gifId/giphy.gif', + backgroundColor: colorScheme.surfaceContainerHighest, + fallbackTextColor: + colorScheme.onSurface.withValues(alpha: 0.6), + width: 160, + height: 110, + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => _textController.clear(), + ), + ], + ); + } + + return TextField( + controller: _textController, + inputFormatters: [ + Utf8LengthLimitingTextInputFormatter(maxBytes), + ], + decoration: const InputDecoration( + hintText: 'Type a message...', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + textInputAction: TextInputAction.send, + onSubmitted: (_) => _sendMessage(connector), + ); + }, + ), + ), + const SizedBox(width: 8), + IconButton.filled( + icon: const Icon(Icons.send), + onPressed: () => _sendMessage(connector), + ), + ], + ), + ), + ); + } + + String? _parseGifId(String text) { + final trimmed = text.trim(); + final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed); + return match?.group(1); + } + + void _showGifPicker(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => GifPicker( + onGifSelected: (gifId) { + _textController.text = 'g:$gifId'; + }, + ), + ); + } + + void _sendMessage(MeshCoreConnector connector) { + final text = _textController.text.trim(); + if (text.isEmpty) return; + + final maxBytes = maxContactMessageBytes(); + if (utf8.encode(text).length > maxBytes) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Message too long (max $maxBytes bytes).')), + ); + return; + } + + connector.sendMessage( + widget.contact, + text, + ); + _textController.clear(); + + Future.delayed(const Duration(milliseconds: 100), () { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + }); + } + + + void _showPathHistory(BuildContext context) { + final connector = Provider.of(context, listen: false); + + showDialog( + context: context, + builder: (context) => Consumer( + builder: (context, pathService, _) { + final paths = pathService.getRecentPaths(widget.contact.publicKeyHex); + return AlertDialog( + title: const Row( + children: [ + Icon(Icons.timeline), + SizedBox(width: 8), + Text('Path Management'), + ], + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (paths.isNotEmpty) ...[ + const Text( + 'Recent ACK Paths (tap to use):', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + if (paths.length >= 100) ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.amber[100], + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'Path history is full. Remove entries to add new ones.', + style: TextStyle(fontSize: 12), + ), + ), + ], + const SizedBox(height: 8), + ...paths.map((path) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + dense: true, + leading: CircleAvatar( + radius: 16, + backgroundColor: path.wasFloodDiscovery ? Colors.blue : Colors.green, + child: Text( + '${path.hopCount}', + style: const TextStyle(fontSize: 12), + ), + ), + title: Text( + '${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'}', + style: const TextStyle(fontSize: 14), + ), + subtitle: Text( + '${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)} • ${path.successCount} successes', + style: const TextStyle(fontSize: 11), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.close, size: 16), + tooltip: 'Remove path', + onPressed: () async { + await pathService.removePathRecord( + widget.contact.publicKeyHex, + path.pathBytes, + ); + }, + ), + path.wasFloodDiscovery + ? const Icon(Icons.waves, size: 16, color: Colors.grey) + : const Icon(Icons.route, size: 16, color: Colors.grey), + ], + ), + onLongPress: () => _showFullPathDialog(context, path.pathBytes), + onTap: () async { + if (path.pathBytes.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Path details not available yet. Try sending a message to refresh.'), + duration: Duration(seconds: 2), + ), + ); + return; + } + + final pathBytes = Uint8List.fromList(path.pathBytes); + final pathLength = path.pathBytes.length; + + // Set the path override to persist user's choice + await connector.setPathOverride( + widget.contact, + pathLen: pathLength, + pathBytes: pathBytes, + ); + + if (!context.mounted) return; + Navigator.pop(context); + await _notifyPathSet( + connector, + widget.contact, + pathBytes, + path.hopCount, + ); + }, + ), + ); + }), + const Divider(), + ] else ...[ + const Text('No path history yet.\nSend a message to discover paths.'), + const Divider(), + ], + const SizedBox(height: 8), + const Text( + 'Path Actions:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + const SizedBox(height: 8), + ListTile( + dense: true, + leading: const CircleAvatar( + radius: 16, + backgroundColor: Colors.purple, + child: Icon(Icons.edit_road, size: 16), + ), + title: const Text('Set Custom Path', style: TextStyle(fontSize: 14)), + subtitle: const Text('Manually specify routing path', style: TextStyle(fontSize: 11)), + onTap: () { + Navigator.pop(context); + _showCustomPathDialog(context); + }, + ), + ListTile( + dense: true, + leading: const CircleAvatar( + radius: 16, + backgroundColor: Colors.orange, + child: Icon(Icons.clear_all, size: 16), + ), + title: const Text('Clear Path', style: TextStyle(fontSize: 14)), + subtitle: const Text('Force rediscovery on next send', style: TextStyle(fontSize: 11)), + onTap: () async { + await connector.clearContactPath(widget.contact); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Path cleared. Next message will rediscover route.'), + duration: Duration(seconds: 2), + ), + ); + Navigator.pop(context); + }, + ), + ListTile( + dense: true, + leading: const CircleAvatar( + radius: 16, + backgroundColor: Colors.blue, + child: Icon(Icons.waves, size: 16), + ), + title: const Text('Force Flood Mode', style: TextStyle(fontSize: 14)), + subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)), + 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.'), + duration: Duration(seconds: 2), + ), + ); + Navigator.pop(context); + }, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ); + }, + ), + ); + } + + String _formatRelativeTime(DateTime time) { + final diff = DateTime.now().difference(time); + if (diff.inSeconds < 60) return 'Just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + return '${diff.inDays}d ago'; + } + + void _showFullPathDialog(BuildContext context, List pathBytes) { + if (pathBytes.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Path details not available yet. Try sending a message to refresh.'), + duration: Duration(seconds: 2), + ), + ); + return; + } + + final formattedPath = pathBytes + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(','); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Full Path'), + content: SelectableText(formattedPath), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ), + ); + } + + Contact _resolveContact(MeshCoreConnector connector) { + return connector.contacts.firstWhere( + (c) => c.publicKeyHex == widget.contact.publicKeyHex, + orElse: () => widget.contact, + ); + } + + Contact _resolveContactFrom4Bytes(MeshCoreConnector connector, Uint8List key4Bytes) { + return connector.contacts.firstWhere( + (c) => listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)), + orElse: () => widget.contact, + ); + } + + 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'; + return '${contact.pathLength} hops'; + } + + Future _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(context, listen: false); + connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex); + + showDialog( + context: context, + builder: (context) => Consumer( + builder: (context, connector, _) { + final contact = _resolveContact(connector); + final smazEnabled = connector.isContactSmazEnabled(contact.publicKeyHex); + + return AlertDialog( + title: Text(contact.name), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow('Type', contact.typeLabel), + _buildInfoRow('Path', contact.pathLabel), + if (contact.hasLocation) + _buildInfoRow( + 'Location', + '${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}', + ), + _buildInfoRow('Public Key', '${contact.publicKeyHex.substring(0, 16)}...'), + const Divider(), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('SMAZ compression'), + subtitle: const Text('Compress outgoing messages'), + value: smazEnabled, + onChanged: (value) { + connector.setContactSmazEnabled(contact.publicKeyHex, value); + }, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ); + }, + ), + ); + } + + Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 80, + child: Text(label, style: TextStyle(color: Colors.grey[600])), + ), + Expanded(child: Text(value)), + ], + ), + ); + } + + Future _showCustomPathDialog(BuildContext context) async { + final connector = Provider.of(context, listen: false); + + final currentContact = _resolveContact(connector); + if (currentContact.pathLength > 0 && currentContact.path.isEmpty && connector.isConnected) { + connector.getContacts(); + } + + final pathForInput = currentContact.pathIdList; + final currentPathLabel = _currentPathLabel(currentContact); + + // Filter out the current contact from available contacts + final availableContacts = connector.contacts + .where((c) => c != widget.contact) + .toList(); + + 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: 'RoomChatScreen'); + + if (result == null) { + appLogger.info('PathSelectionDialog was cancelled or returned null', tag: 'RoomChatScreen'); + return; + } + + if (!mounted) { + appLogger.warn('Widget not mounted after dialog, cannot set path', tag: 'RoomChatScreen'); + return; + } + + appLogger.info('Calling setPathOverride for ${widget.contact.name}', tag: 'RoomChatScreen'); + await connector.setPathOverride( + widget.contact, + pathLen: result.length, + pathBytes: result, + ); + appLogger.info('setPathOverride completed', tag: 'RoomChatScreen'); + + if (!mounted) return; + await _notifyPathSet(connector, widget.contact, result, result.length); + } + + + void _openMessagePath(Message message) { + final connector = context.read(); + final senderName = + message.isOutgoing ? (connector.selfName ?? 'Me') : widget.contact.name; + final pathMessage = ChannelMessage( + senderKey: null, + senderName: senderName, + text: message.text, + timestamp: message.timestamp, + isOutgoing: message.isOutgoing, + status: ChannelMessageStatus.sent, + repeatCount: 0, + pathLength: message.pathLength, + pathBytes: message.pathBytes, + ); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChannelMessagePathScreen(message: pathMessage), + ), + ); + } + + void _showMessageActions(Message message) { + showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.add_reaction_outlined), + title: const Text('Add Reaction'), + onTap: () { + Navigator.pop(sheetContext); + _showEmojiPicker(message); + }, + ), + ListTile( + leading: const Icon(Icons.copy), + title: const Text('Copy'), + onTap: () { + Navigator.pop(sheetContext); + _copyMessageText(message.text); + }, + ), + ListTile( + leading: const Icon(Icons.delete_outline), + title: const Text('Delete'), + onTap: () async { + Navigator.pop(sheetContext); + await _deleteMessage(message); + }, + ), + if (message.isOutgoing && + message.status == MessageStatus.failed) + ListTile( + leading: const Icon(Icons.refresh), + title: const Text('Retry'), + onTap: () { + Navigator.pop(sheetContext); + _retryMessage(message); + }, + ), + ListTile( + leading: const Icon(Icons.close), + title: const Text('Cancel'), + onTap: () => Navigator.pop(sheetContext), + ), + ], + ), + ), + ); + } + + void _copyMessageText(String text) { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Message copied')), + ); + } + + Future _deleteMessage(Message message) async { + await context.read().deleteMessage(message); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Message deleted')), + ); + } + + void _retryMessage(Message message) { + final connector = Provider.of(context, listen: false); + // Retry using the contact's current path override setting + connector.sendMessage( + widget.contact, + message.text, + ); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Retrying message')), + ); + } + + void _showEmojiPicker(Message message) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => EmojiPicker( + onEmojiSelected: (emoji) { + _sendReaction(message, emoji); + }, + ), + ); + } + + void _sendReaction(Message message, String emoji) { + final connector = context.read(); + // 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); + } +} + +class _MessageBubble extends StatelessWidget { + final Message message; + final String senderName; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + + const _MessageBubble({ + required this.message, + required this.senderName, + this.onTap, + this.onLongPress, + }); + + @override + Widget build(BuildContext context) { + final isOutgoing = message.isOutgoing; + final colorScheme = Theme.of(context).colorScheme; + final gifId = _parseGifId(message.text); + final poi = _parsePoiMessage(message.text); + final isFailed = message.status == MessageStatus.failed; + final bubbleColor = isFailed + ? colorScheme.errorContainer + : (isOutgoing ? colorScheme.primary : colorScheme.surfaceContainerHighest); + final textColor = isFailed + ? colorScheme.onErrorContainer + : (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface); + final metaColor = textColor.withValues(alpha: 0.7); + final bytes4 = Uint8List.fromList(message.text.substring(0, 4.clamp(0, message.text.length)).codeUnits); + final hexString = bytes4.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase(); + final messageText = message.text.substring(4.clamp(0, message.text.length)); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: Row( + mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isOutgoing) ...[ + _buildAvatar(senderName, colorScheme), + const SizedBox(width: 8), + ], + Flexible( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.65, + ), + decoration: BoxDecoration( + color: bubbleColor, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isOutgoing) ...[ + Text( + senderName, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: colorScheme.primary, + ), + ), + const SizedBox(height: 4), + ], + if (poi != null) + _buildPoiMessage(context, poi, textColor, metaColor) + else if (gifId != null) + GifMessage( + url: 'https://media.giphy.com/media/$gifId/giphy.gif', + backgroundColor: bubbleColor, + fallbackTextColor: textColor.withValues(alpha: 0.7), + ) + else + if(!isOutgoing) + + Text( + "[$hexString] $messageText", + style: TextStyle( + color: textColor, + ), + ), + if(isOutgoing) + Text( + message.text, + style: TextStyle( + color: textColor, + ), + ), + + if (isOutgoing && message.retryCount > 0) ...[ + const SizedBox(height: 4), + Text( + 'Retry ${message.retryCount}/4', + style: TextStyle( + fontSize: 10, + color: metaColor, + fontWeight: FontWeight.w500, + ), + ), + ], + const SizedBox(height: 4), + Wrap( + spacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + _formatTime(message.timestamp), + style: TextStyle( + fontSize: 10, + color: metaColor, + ), + ), + if (isOutgoing) ...[ + const SizedBox(width: 4), + _buildStatusIcon(metaColor), + ], + if (message.tripTimeMs != null && + message.status == MessageStatus.delivered) ...[ + const SizedBox(width: 4), + Icon( + Icons.speed, + size: 10, + color: isOutgoing ? metaColor : Colors.green[700], + ), + Text( + '${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s', + style: TextStyle( + fontSize: 9, + color: isOutgoing ? metaColor : Colors.green[700], + ), + ), + ], + ], + ), + ], + ), + ), + ), + ], + ), + ), + if (message.reactions.isNotEmpty) ...[ + const SizedBox(height: 4), + Padding( + padding: EdgeInsets.only(left: isOutgoing ? 0 : 48), + child: _buildReactionsDisplay(context, message, colorScheme), + ), + ], + ], + ), + ); + } + + String? _parseGifId(String text) { + final trimmed = text.trim(); + final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed); + return match?.group(1); + } + + _PoiInfo? _parsePoiMessage(String text) { + final trimmed = text.trim(); + final match = RegExp(r'^m:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|.*$') + .firstMatch(trimmed); + if (match == null) return null; + final lat = double.tryParse(match.group(1) ?? ''); + final lon = double.tryParse(match.group(2) ?? ''); + if (lat == null || lon == null) return null; + final label = match.group(3) ?? ''; + return _PoiInfo(lat: lat, lon: lon, label: label); + } + + Widget _buildPoiMessage( + BuildContext context, + _PoiInfo poi, + Color textColor, + Color metaColor, + ) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + icon: Icon(Icons.location_on_outlined, color: textColor), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MapScreen( + highlightPosition: LatLng(poi.lat, poi.lon), + highlightLabel: poi.label, + ), + ), + ); + }, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'POI Shared', + style: TextStyle( + color: textColor, + fontWeight: FontWeight.w600, + ), + ), + if (poi.label.isNotEmpty) + Text( + poi.label, + style: TextStyle( + color: metaColor, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildReactionsDisplay(BuildContext context, Message message, ColorScheme colorScheme) { + return Wrap( + spacing: 6, + runSpacing: 6, + children: message.reactions.entries.map((entry) { + final emoji = entry.key; + final count = entry.value; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + emoji, + style: const TextStyle(fontSize: 16), + ), + if (count > 1) ...[ + const SizedBox(width: 4), + Text( + '$count', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: colorScheme.onSecondaryContainer, + ), + ), + ], + ], + ), + ); + }).toList(), + ); + } + + Widget _buildAvatar(String senderName, ColorScheme colorScheme) { + final initial = _getFirstCharacterOrEmoji(senderName); + final color = _getColorForName(senderName); + + return CircleAvatar( + radius: 18, + backgroundColor: color.withValues(alpha: 0.2), + child: Text( + initial, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ); + } + + String _getFirstCharacterOrEmoji(String name) { + if (name.isEmpty) return '?'; + + final emoji = firstEmoji(name); + if (emoji != null) return emoji; + + final runes = name.runes.toList(); + if (runes.isEmpty) return '?'; + return String.fromCharCode(runes[0]).toUpperCase(); + } + + Color _getColorForName(String name) { + // Generate a consistent color based on the name hash + final hash = name.hashCode; + final colors = [ + Colors.blue, + Colors.green, + Colors.orange, + Colors.purple, + Colors.pink, + Colors.teal, + Colors.indigo, + Colors.cyan, + Colors.amber, + Colors.deepOrange, + ]; + + return colors[hash.abs() % colors.length]; + } + + Widget _buildStatusIcon(Color color) { + IconData icon; + switch (message.status) { + case MessageStatus.pending: + icon = Icons.access_time; + break; + case MessageStatus.sent: + icon = Icons.schedule; + break; + case MessageStatus.delivered: + icon = Icons.check; + break; + case MessageStatus.failed: + icon = Icons.error_outline; + break; + } + + return Icon( + icon, + size: 12, + color: color, + ); + } + + String _formatTime(DateTime time) { + final hour = time.hour.toString().padLeft(2, '0'); + final minute = time.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; + } +} + +class _PoiInfo { + final double lat; + final double lon; + final String label; + + const _PoiInfo({ + required this.lat, + required this.lon, + required this.label, + }); +} From 367f89fb1bde804008df031f5be351ff4d7f3850 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Fri, 9 Jan 2026 00:03:50 -0800 Subject: [PATCH 3/6] Added value to Message fourByteRoomContactKey which holds the first 4 bytes of the contacts pub key that posted the message to the room. --- lib/models/message.dart | 5 +++++ lib/screens/room_chat_screen.dart | 17 ++++++++--------- lib/storage/message_store.dart | 2 ++ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/lib/models/message.dart b/lib/models/message.dart index cbcd111..bd397d7 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -23,6 +23,7 @@ class Message { final int? pathLength; final Uint8List pathBytes; final Map reactions; + final Uint8List fourByteRoomContactKey; Message({ required this.senderKey, @@ -40,8 +41,10 @@ class Message { this.tripTimeMs, this.pathLength, Uint8List? pathBytes, + Uint8List? fourByteRoomContactKey, Map? reactions, }) : pathBytes = pathBytes ?? Uint8List(0), + fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0), reactions = reactions ?? {}; String get senderKeyHex => pubKeyToHex(senderKey); @@ -58,6 +61,7 @@ class Message { Uint8List? pathBytes, bool? isCli, Map? reactions, + Uint8List? fourByteRoomContactKey, }) { return Message( senderKey: senderKey, @@ -76,6 +80,7 @@ class Message { pathLength: pathLength ?? this.pathLength, pathBytes: pathBytes ?? this.pathBytes, reactions: reactions ?? this.reactions, + fourByteRoomContactKey: fourByteRoomContactKey ?? this.fourByteRoomContactKey, ); } diff --git a/lib/screens/room_chat_screen.dart b/lib/screens/room_chat_screen.dart index 969a7e1..19db334 100644 --- a/lib/screens/room_chat_screen.dart +++ b/lib/screens/room_chat_screen.dart @@ -209,13 +209,13 @@ class _RoomChatScreenState extends State { final message = messages[index]; final contact = _resolveContactFrom4Bytes( connector, - Uint8List.fromList(message.text.substring(0, 4.clamp(0, message.text.length)).codeUnits), + message.fourByteRoomContactKey.isEmpty ? Uint8List.fromList([0, 0, 0, 0]) : message.fourByteRoomContactKey, ); - + final fourByteHex = message.fourByteRoomContactKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase(); return _MessageBubble( message: message, - senderName: contact.name, - onTap: () => _openMessagePath(message), + senderName: "${contact.name} [$fourByteHex]", + onTap: () => _openMessagePath(message, contact), onLongPress: () => _showMessageActions(message), ); }, @@ -747,10 +747,11 @@ class _RoomChatScreenState extends State { } - void _openMessagePath(Message message) { + void _openMessagePath(Message message, Contact contact) { final connector = context.read(); + final fourByteHex = message.fourByteRoomContactKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase(); final senderName = - message.isOutgoing ? (connector.selfName ?? 'Me') : widget.contact.name; + message.isOutgoing ? (connector.selfName ?? 'Me') : "${contact.name} [$fourByteHex]"; final pathMessage = ChannelMessage( senderKey: null, senderName: senderName, @@ -899,8 +900,6 @@ class _MessageBubble extends StatelessWidget { ? colorScheme.onErrorContainer : (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface); final metaColor = textColor.withValues(alpha: 0.7); - final bytes4 = Uint8List.fromList(message.text.substring(0, 4.clamp(0, message.text.length)).codeUnits); - final hexString = bytes4.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase(); final messageText = message.text.substring(4.clamp(0, message.text.length)); return Padding( padding: const EdgeInsets.symmetric(vertical: 4), @@ -954,7 +953,7 @@ class _MessageBubble extends StatelessWidget { if(!isOutgoing) Text( - "[$hexString] $messageText", + messageText, style: TextStyle( color: textColor, ), diff --git a/lib/storage/message_store.dart b/lib/storage/message_store.dart index 2870d25..8f9e703 100644 --- a/lib/storage/message_store.dart +++ b/lib/storage/message_store.dart @@ -52,6 +52,7 @@ class MessageStore { 'pathLength': msg.pathLength, 'pathBytes': msg.pathBytes.isNotEmpty ? base64Encode(msg.pathBytes) : null, 'reactions': msg.reactions, + 'fourByteRoomContactKey': base64Encode(msg.fourByteRoomContactKey), }; } @@ -86,6 +87,7 @@ class MessageStore { reactions: (json['reactions'] as Map?)?.map( (key, value) => MapEntry(key, value as int), ) ?? {}, + fourByteRoomContactKey: Uint8List.fromList(base64Decode(json['fourByteRoomContactKey'] as String)), ); } } From f3aef4233102a8468729375b5d05eff9a38a3bd0 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Fri, 9 Jan 2026 00:04:30 -0800 Subject: [PATCH 4/6] changed noification to support messages from room server. --- lib/connector/meshcore_connector.dart | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 166c98f..a929f19 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -1996,12 +1996,21 @@ class MeshCoreConnector extends ChangeNotifier { final settings = _appSettingsService!.settings; if (settings.notificationsEnabled && settings.notifyOnNewMessage) { // Find the contact name - _notificationService.showMessageNotification( - contactName: contact?.name ?? 'Unknown', - message: message.text, - contactId: message.senderKeyHex, - badgeCount: getTotalUnreadCount(), - ); + if(contact?.type == advTypeChat) { + _notificationService.showMessageNotification( + contactName: contact?.name ?? 'Unknown', + message: message.text, + contactId: message.senderKeyHex, + badgeCount: getTotalUnreadCount(), + ); + }else if(contact?.type == advTypeRoom) { + _notificationService.showMessageNotification( + contactName: contact?.name ?? 'Unknown Room', + message: message.text.substring(4), + contactId: message.senderKeyHex, + badgeCount: getTotalUnreadCount(), + ); + } } } _handleQueuedMessageReceived(); @@ -2027,7 +2036,7 @@ class MeshCoreConnector extends ChangeNotifier { final baseTextOffset = timestampOffset + 4; if (frame.length <= baseTextOffset) return null; - + final fourBytePubMSG = frame.sublist(baseTextOffset, baseTextOffset + 4); final senderPrefix = frame.sublist(prefixOffset, prefixOffset + prefixLen); final flags = frame[txtTypeOffset]; final shiftedType = flags >> 2; @@ -2065,6 +2074,7 @@ class MeshCoreConnector extends ChangeNotifier { status: MessageStatus.delivered, pathLength: pathLenByte == 0xFF ? 0 : pathLenByte, pathBytes: Uint8List(0), + fourByteRoomContactKey: fourBytePubMSG ); } From ab7cc84db588cdd7eb6fc5ce57fee15041d9a4ef Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Fri, 9 Jan 2026 23:44:42 -0800 Subject: [PATCH 5/6] moved roomserver chat into chat_screen --- lib/screens/chat_screen.dart | 79 +- lib/screens/contacts_screen.dart | 3 +- lib/screens/room_chat_screen.dart | 1235 ----------------------------- 3 files changed, 65 insertions(+), 1252 deletions(-) delete mode 100644 lib/screens/room_chat_screen.dart diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 62da512..bb1388c 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; @@ -162,13 +163,12 @@ class _ChatScreenState extends State { body: Consumer( builder: (context, connector, child) { final messages = connector.getMessages(widget.contact); - return Column( children: [ Expanded( child: messages.isEmpty ? _buildEmptyState() - : _buildMessageList(messages), + : _buildMessageList(messages, connector), ), _buildInputBar(connector), ], @@ -199,18 +199,29 @@ class _ChatScreenState extends State { ); } - Widget _buildMessageList(List messages) { + Widget _buildMessageList(List messages, MeshCoreConnector connector) { return ListView.builder( controller: _scrollController, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16), itemCount: messages.length, itemBuilder: (context, index) { + Contact contact = widget.contact; final message = messages[index]; + String fourByteHex = ''; + if(widget.contact.type == advTypeRoom) { + contact = _resolveContactFrom4Bytes( + connector, + message.fourByteRoomContactKey.isEmpty ? Uint8List.fromList([0, 0, 0, 0]) : message.fourByteRoomContactKey, + ); + fourByteHex = message.fourByteRoomContactKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase(); + } + return _MessageBubble( message: message, - senderName: widget.contact.name, - onTap: () => _openMessagePath(message), - onLongPress: () => _showMessageActions(message), + senderName: widget.contact.type == advTypeRoom ? "${contact.name} [$fourByteHex]" : contact.name, + isRoomServer: widget.contact.type == advTypeRoom, + onTap: () => _openMessagePath(message, contact), + onLongPress: () => _showMessageActions(message, contact), ); }, ); @@ -579,6 +590,13 @@ class _ChatScreenState extends State { ); } + Contact _resolveContactFrom4Bytes(MeshCoreConnector connector, Uint8List key4Bytes) { + return connector.contacts.firstWhere( + (c) => listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)), + orElse: () => widget.contact, + ); + } + String _currentPathLabel(Contact contact) { // Check if user has set a path override if (contact.pathOverride != null) { @@ -684,6 +702,15 @@ class _ChatScreenState extends State { ); } + void _openChat(BuildContext context, Contact contact) { + // Check if this is a repeater + context.read().markContactRead(contact.publicKeyHex); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)), + ); + } + Future _showCustomPathDialog(BuildContext context) async { final connector = Provider.of(context, listen: false); @@ -734,10 +761,11 @@ class _ChatScreenState extends State { } - void _openMessagePath(Message message) { + void _openMessagePath(Message message, Contact contact) { final connector = context.read(); + final fourByteHex = message.fourByteRoomContactKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase(); final senderName = - message.isOutgoing ? (connector.selfName ?? 'Me') : widget.contact.name; + message.isOutgoing ? (connector.selfName ?? 'Me') : widget.contact.type == advTypeRoom ? "${contact.name} [$fourByteHex]" : widget.contact.name; final pathMessage = ChannelMessage( senderKey: null, senderName: senderName, @@ -757,7 +785,7 @@ class _ChatScreenState extends State { ); } - void _showMessageActions(Message message) { + void _showMessageActions(Message message, Contact contact) { showModalBottomSheet( context: context, builder: (sheetContext) => SafeArea( @@ -798,6 +826,14 @@ class _ChatScreenState extends State { _retryMessage(message); }, ), + if(widget.contact.type == advTypeRoom) + ListTile( + leading: const Icon(Icons.chat), + title: const Text('Open Chat'), + onTap: () { + _openChat(context, contact); + }, + ), ListTile( leading: const Icon(Icons.close), title: const Text('Cancel'), @@ -862,12 +898,14 @@ class _ChatScreenState extends State { class _MessageBubble extends StatelessWidget { final Message message; final String senderName; + final bool isRoomServer; final VoidCallback? onTap; final VoidCallback? onLongPress; const _MessageBubble({ required this.message, required this.senderName, + required this.isRoomServer, this.onTap, this.onLongPress, }); @@ -886,7 +924,10 @@ class _MessageBubble extends StatelessWidget { ? colorScheme.onErrorContainer : (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface); final metaColor = textColor.withValues(alpha: 0.7); - + String messageText = message.text; + if (isRoomServer && !isOutgoing) { + messageText = message.text.substring(4.clamp(0, message.text.length)); + } return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( @@ -936,12 +977,20 @@ class _MessageBubble extends StatelessWidget { fallbackTextColor: textColor.withValues(alpha: 0.7), ) else - Text( - message.text, - style: TextStyle( - color: textColor, + if(!isOutgoing) + Text( + messageText, + style: TextStyle( + color: textColor, + ), + ), + if(isOutgoing) + Text( + message.text, + style: TextStyle( + color: textColor, + ), ), - ), if (isOutgoing && message.retryCount > 0) ...[ const SizedBox(height: 4), Text( diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 896101f..eed38df 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -22,7 +22,6 @@ import '../widgets/room_login_dialog.dart'; import '../widgets/unread_badge.dart'; import 'channels_screen.dart'; import 'chat_screen.dart'; -import 'room_chat_screen.dart'; import 'map_screen.dart'; import 'repeater_hub_screen.dart'; import 'settings_screen.dart'; @@ -451,7 +450,7 @@ class _ContactsScreenState extends State Navigator.push( context, MaterialPageRoute( - builder: (context) => RoomChatScreen(contact: room), + builder: (context) => ChatScreen(contact: room), ), ); }, diff --git a/lib/screens/room_chat_screen.dart b/lib/screens/room_chat_screen.dart deleted file mode 100644 index 19db334..0000000 --- a/lib/screens/room_chat_screen.dart +++ /dev/null @@ -1,1235 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/foundation.dart'; -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'; - -import '../connector/meshcore_connector.dart'; -import '../connector/meshcore_protocol.dart'; -import '../helpers/utf8_length_limiter.dart'; -import '../models/channel_message.dart'; -import '../models/contact.dart'; -import '../models/message.dart'; -import '../services/path_history_service.dart'; -import 'channel_message_path_screen.dart'; -import 'map_screen.dart'; -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 RoomChatScreen extends StatefulWidget { - final Contact contact; - - const RoomChatScreen({super.key, required this.contact}); - - @override - State createState() => _RoomChatScreenState(); -} - -class _RoomChatScreenState extends State { - final _textController = TextEditingController(); - final _scrollController = ScrollController(); - - @override - void initState() { - super.initState(); - SchedulerBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - context.read().setActiveContact(widget.contact.publicKeyHex); - - // Scroll to bottom when opening chat use SchedulerBinding for next frame - if (_scrollController.hasClients) { - _scrollController.jumpTo(_scrollController.position.maxScrollExtent); - } - }); - } - - @override - void dispose() { - context.read().setActiveContact(null); - _textController.dispose(); - _scrollController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Consumer2( - builder: (context, pathService, connector, _) { - final contact = _resolveContact(connector); - final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex); - final unreadLabel = 'Unread: $unreadCount'; - 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), - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: hasPathData ? () => _showFullPathDialog(context, effectivePath) : null, - child: Text( - '$pathLabel • $unreadLabel', - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.normal, - decoration: hasPathData ? TextDecoration.underline : null, - decorationStyle: TextDecorationStyle.dotted, - ), - ), - ), - ], - ); - }, - ), - centerTitle: false, - actions: [ - Consumer( - builder: (context, connector, _) { - final contact = _resolveContact(connector); - final isFloodMode = contact.pathOverride == -1; - - return PopupMenuButton( - 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, - ), - ), - ], - ), - ), - ], - ); - }, - ), - IconButton( - icon: const Icon(Icons.timeline), - tooltip: 'Path management', - onPressed: () => _showPathHistory(context), - ), - IconButton( - icon: const Icon(Icons.info_outline), - onPressed: () => _showContactInfo(context), - ), - ], - ), - body: Consumer( - builder: (context, connector, child) { - final messages = connector.getMessages(widget.contact); - return Column( - children: [ - Expanded( - child: messages.isEmpty - ? _buildEmptyState() - : _buildMessageList(messages, connector), - ), - _buildInputBar(connector), - ], - ); - }, - ), - ); - } - - Widget _buildEmptyState() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey[400]), - const SizedBox(height: 16), - Text( - 'No messages yet', - style: TextStyle(fontSize: 16, color: Colors.grey[600]), - ), - const SizedBox(height: 8), - Text( - 'Send a message to ${widget.contact.name}', - style: TextStyle(fontSize: 14, color: Colors.grey[500]), - ), - ], - ), - ); - } - - Widget _buildMessageList(List messages, MeshCoreConnector connector) { - - return ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16), - itemCount: messages.length, - itemBuilder: (context, index) { - final message = messages[index]; - final contact = _resolveContactFrom4Bytes( - connector, - message.fourByteRoomContactKey.isEmpty ? Uint8List.fromList([0, 0, 0, 0]) : message.fourByteRoomContactKey, - ); - final fourByteHex = message.fourByteRoomContactKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase(); - return _MessageBubble( - message: message, - senderName: "${contact.name} [$fourByteHex]", - onTap: () => _openMessagePath(message, contact), - onLongPress: () => _showMessageActions(message), - ); - }, - ); - } - - Widget _buildInputBar(MeshCoreConnector connector) { - final maxBytes = maxContactMessageBytes(); - final colorScheme = Theme.of(context).colorScheme; - return Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: colorScheme.surface, - border: Border( - top: BorderSide(color: Theme.of(context).dividerColor), - ), - ), - child: SafeArea( - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.gif_box), - onPressed: () => _showGifPicker(context), - tooltip: 'Send GIF', - ), - Expanded( - child: ValueListenableBuilder( - valueListenable: _textController, - builder: (context, value, child) { - final gifId = _parseGifId(value.text); - if (gifId != null) { - return Row( - children: [ - Expanded( - child: GifMessage( - url: 'https://media.giphy.com/media/$gifId/giphy.gif', - backgroundColor: colorScheme.surfaceContainerHighest, - fallbackTextColor: - colorScheme.onSurface.withValues(alpha: 0.6), - width: 160, - height: 110, - ), - ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => _textController.clear(), - ), - ], - ); - } - - return TextField( - controller: _textController, - inputFormatters: [ - Utf8LengthLimitingTextInputFormatter(maxBytes), - ], - decoration: const InputDecoration( - hintText: 'Type a message...', - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), - ), - textInputAction: TextInputAction.send, - onSubmitted: (_) => _sendMessage(connector), - ); - }, - ), - ), - const SizedBox(width: 8), - IconButton.filled( - icon: const Icon(Icons.send), - onPressed: () => _sendMessage(connector), - ), - ], - ), - ), - ); - } - - String? _parseGifId(String text) { - final trimmed = text.trim(); - final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed); - return match?.group(1); - } - - void _showGifPicker(BuildContext context) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) => GifPicker( - onGifSelected: (gifId) { - _textController.text = 'g:$gifId'; - }, - ), - ); - } - - void _sendMessage(MeshCoreConnector connector) { - final text = _textController.text.trim(); - if (text.isEmpty) return; - - final maxBytes = maxContactMessageBytes(); - if (utf8.encode(text).length > maxBytes) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Message too long (max $maxBytes bytes).')), - ); - return; - } - - connector.sendMessage( - widget.contact, - text, - ); - _textController.clear(); - - Future.delayed(const Duration(milliseconds: 100), () { - if (_scrollController.hasClients) { - _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - ); - } - }); - } - - - void _showPathHistory(BuildContext context) { - final connector = Provider.of(context, listen: false); - - showDialog( - context: context, - builder: (context) => Consumer( - builder: (context, pathService, _) { - final paths = pathService.getRecentPaths(widget.contact.publicKeyHex); - return AlertDialog( - title: const Row( - children: [ - Icon(Icons.timeline), - SizedBox(width: 8), - Text('Path Management'), - ], - ), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (paths.isNotEmpty) ...[ - const Text( - 'Recent ACK Paths (tap to use):', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), - ), - if (paths.length >= 100) ...[ - const SizedBox(height: 8), - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Colors.amber[100], - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - 'Path history is full. Remove entries to add new ones.', - style: TextStyle(fontSize: 12), - ), - ), - ], - const SizedBox(height: 8), - ...paths.map((path) { - return Card( - margin: const EdgeInsets.symmetric(vertical: 4), - child: ListTile( - dense: true, - leading: CircleAvatar( - radius: 16, - backgroundColor: path.wasFloodDiscovery ? Colors.blue : Colors.green, - child: Text( - '${path.hopCount}', - style: const TextStyle(fontSize: 12), - ), - ), - title: Text( - '${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'}', - style: const TextStyle(fontSize: 14), - ), - subtitle: Text( - '${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)} • ${path.successCount} successes', - style: const TextStyle(fontSize: 11), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.close, size: 16), - tooltip: 'Remove path', - onPressed: () async { - await pathService.removePathRecord( - widget.contact.publicKeyHex, - path.pathBytes, - ); - }, - ), - path.wasFloodDiscovery - ? const Icon(Icons.waves, size: 16, color: Colors.grey) - : const Icon(Icons.route, size: 16, color: Colors.grey), - ], - ), - onLongPress: () => _showFullPathDialog(context, path.pathBytes), - onTap: () async { - if (path.pathBytes.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Path details not available yet. Try sending a message to refresh.'), - duration: Duration(seconds: 2), - ), - ); - return; - } - - final pathBytes = Uint8List.fromList(path.pathBytes); - final pathLength = path.pathBytes.length; - - // Set the path override to persist user's choice - await connector.setPathOverride( - widget.contact, - pathLen: pathLength, - pathBytes: pathBytes, - ); - - if (!context.mounted) return; - Navigator.pop(context); - await _notifyPathSet( - connector, - widget.contact, - pathBytes, - path.hopCount, - ); - }, - ), - ); - }), - const Divider(), - ] else ...[ - const Text('No path history yet.\nSend a message to discover paths.'), - const Divider(), - ], - const SizedBox(height: 8), - const Text( - 'Path Actions:', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), - ), - const SizedBox(height: 8), - ListTile( - dense: true, - leading: const CircleAvatar( - radius: 16, - backgroundColor: Colors.purple, - child: Icon(Icons.edit_road, size: 16), - ), - title: const Text('Set Custom Path', style: TextStyle(fontSize: 14)), - subtitle: const Text('Manually specify routing path', style: TextStyle(fontSize: 11)), - onTap: () { - Navigator.pop(context); - _showCustomPathDialog(context); - }, - ), - ListTile( - dense: true, - leading: const CircleAvatar( - radius: 16, - backgroundColor: Colors.orange, - child: Icon(Icons.clear_all, size: 16), - ), - title: const Text('Clear Path', style: TextStyle(fontSize: 14)), - subtitle: const Text('Force rediscovery on next send', style: TextStyle(fontSize: 11)), - onTap: () async { - await connector.clearContactPath(widget.contact); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Path cleared. Next message will rediscover route.'), - duration: Duration(seconds: 2), - ), - ); - Navigator.pop(context); - }, - ), - ListTile( - dense: true, - leading: const CircleAvatar( - radius: 16, - backgroundColor: Colors.blue, - child: Icon(Icons.waves, size: 16), - ), - title: const Text('Force Flood Mode', style: TextStyle(fontSize: 14)), - subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)), - 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.'), - duration: Duration(seconds: 2), - ), - ); - Navigator.pop(context); - }, - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close'), - ), - ], - ); - }, - ), - ); - } - - String _formatRelativeTime(DateTime time) { - final diff = DateTime.now().difference(time); - if (diff.inSeconds < 60) return 'Just now'; - if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; - if (diff.inHours < 24) return '${diff.inHours}h ago'; - return '${diff.inDays}d ago'; - } - - void _showFullPathDialog(BuildContext context, List pathBytes) { - if (pathBytes.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Path details not available yet. Try sending a message to refresh.'), - duration: Duration(seconds: 2), - ), - ); - return; - } - - final formattedPath = pathBytes - .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) - .join(','); - - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Full Path'), - content: SelectableText(formattedPath), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close'), - ), - ], - ), - ); - } - - Contact _resolveContact(MeshCoreConnector connector) { - return connector.contacts.firstWhere( - (c) => c.publicKeyHex == widget.contact.publicKeyHex, - orElse: () => widget.contact, - ); - } - - Contact _resolveContactFrom4Bytes(MeshCoreConnector connector, Uint8List key4Bytes) { - return connector.contacts.firstWhere( - (c) => listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)), - orElse: () => widget.contact, - ); - } - - 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'; - return '${contact.pathLength} hops'; - } - - Future _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(context, listen: false); - connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex); - - showDialog( - context: context, - builder: (context) => Consumer( - builder: (context, connector, _) { - final contact = _resolveContact(connector); - final smazEnabled = connector.isContactSmazEnabled(contact.publicKeyHex); - - return AlertDialog( - title: Text(contact.name), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildInfoRow('Type', contact.typeLabel), - _buildInfoRow('Path', contact.pathLabel), - if (contact.hasLocation) - _buildInfoRow( - 'Location', - '${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}', - ), - _buildInfoRow('Public Key', '${contact.publicKeyHex.substring(0, 16)}...'), - const Divider(), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('SMAZ compression'), - subtitle: const Text('Compress outgoing messages'), - value: smazEnabled, - onChanged: (value) { - connector.setContactSmazEnabled(contact.publicKeyHex, value); - }, - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close'), - ), - ], - ); - }, - ), - ); - } - - Widget _buildInfoRow(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 80, - child: Text(label, style: TextStyle(color: Colors.grey[600])), - ), - Expanded(child: Text(value)), - ], - ), - ); - } - - Future _showCustomPathDialog(BuildContext context) async { - final connector = Provider.of(context, listen: false); - - final currentContact = _resolveContact(connector); - if (currentContact.pathLength > 0 && currentContact.path.isEmpty && connector.isConnected) { - connector.getContacts(); - } - - final pathForInput = currentContact.pathIdList; - final currentPathLabel = _currentPathLabel(currentContact); - - // Filter out the current contact from available contacts - final availableContacts = connector.contacts - .where((c) => c != widget.contact) - .toList(); - - 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: 'RoomChatScreen'); - - if (result == null) { - appLogger.info('PathSelectionDialog was cancelled or returned null', tag: 'RoomChatScreen'); - return; - } - - if (!mounted) { - appLogger.warn('Widget not mounted after dialog, cannot set path', tag: 'RoomChatScreen'); - return; - } - - appLogger.info('Calling setPathOverride for ${widget.contact.name}', tag: 'RoomChatScreen'); - await connector.setPathOverride( - widget.contact, - pathLen: result.length, - pathBytes: result, - ); - appLogger.info('setPathOverride completed', tag: 'RoomChatScreen'); - - if (!mounted) return; - await _notifyPathSet(connector, widget.contact, result, result.length); - } - - - void _openMessagePath(Message message, Contact contact) { - final connector = context.read(); - final fourByteHex = message.fourByteRoomContactKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase(); - final senderName = - message.isOutgoing ? (connector.selfName ?? 'Me') : "${contact.name} [$fourByteHex]"; - final pathMessage = ChannelMessage( - senderKey: null, - senderName: senderName, - text: message.text, - timestamp: message.timestamp, - isOutgoing: message.isOutgoing, - status: ChannelMessageStatus.sent, - repeatCount: 0, - pathLength: message.pathLength, - pathBytes: message.pathBytes, - ); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChannelMessagePathScreen(message: pathMessage), - ), - ); - } - - void _showMessageActions(Message message) { - showModalBottomSheet( - context: context, - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.add_reaction_outlined), - title: const Text('Add Reaction'), - onTap: () { - Navigator.pop(sheetContext); - _showEmojiPicker(message); - }, - ), - ListTile( - leading: const Icon(Icons.copy), - title: const Text('Copy'), - onTap: () { - Navigator.pop(sheetContext); - _copyMessageText(message.text); - }, - ), - ListTile( - leading: const Icon(Icons.delete_outline), - title: const Text('Delete'), - onTap: () async { - Navigator.pop(sheetContext); - await _deleteMessage(message); - }, - ), - if (message.isOutgoing && - message.status == MessageStatus.failed) - ListTile( - leading: const Icon(Icons.refresh), - title: const Text('Retry'), - onTap: () { - Navigator.pop(sheetContext); - _retryMessage(message); - }, - ), - ListTile( - leading: const Icon(Icons.close), - title: const Text('Cancel'), - onTap: () => Navigator.pop(sheetContext), - ), - ], - ), - ), - ); - } - - void _copyMessageText(String text) { - Clipboard.setData(ClipboardData(text: text)); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Message copied')), - ); - } - - Future _deleteMessage(Message message) async { - await context.read().deleteMessage(message); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Message deleted')), - ); - } - - void _retryMessage(Message message) { - final connector = Provider.of(context, listen: false); - // Retry using the contact's current path override setting - connector.sendMessage( - widget.contact, - message.text, - ); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Retrying message')), - ); - } - - void _showEmojiPicker(Message message) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) => EmojiPicker( - onEmojiSelected: (emoji) { - _sendReaction(message, emoji); - }, - ), - ); - } - - void _sendReaction(Message message, String emoji) { - final connector = context.read(); - // 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); - } -} - -class _MessageBubble extends StatelessWidget { - final Message message; - final String senderName; - final VoidCallback? onTap; - final VoidCallback? onLongPress; - - const _MessageBubble({ - required this.message, - required this.senderName, - this.onTap, - this.onLongPress, - }); - - @override - Widget build(BuildContext context) { - final isOutgoing = message.isOutgoing; - final colorScheme = Theme.of(context).colorScheme; - final gifId = _parseGifId(message.text); - final poi = _parsePoiMessage(message.text); - final isFailed = message.status == MessageStatus.failed; - final bubbleColor = isFailed - ? colorScheme.errorContainer - : (isOutgoing ? colorScheme.primary : colorScheme.surfaceContainerHighest); - final textColor = isFailed - ? colorScheme.onErrorContainer - : (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface); - final metaColor = textColor.withValues(alpha: 0.7); - final messageText = message.text.substring(4.clamp(0, message.text.length)); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Column( - crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start, - children: [ - GestureDetector( - onTap: onTap, - onLongPress: onLongPress, - child: Row( - mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isOutgoing) ...[ - _buildAvatar(senderName, colorScheme), - const SizedBox(width: 8), - ], - Flexible( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.65, - ), - decoration: BoxDecoration( - color: bubbleColor, - borderRadius: BorderRadius.circular(16), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isOutgoing) ...[ - Text( - senderName, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: colorScheme.primary, - ), - ), - const SizedBox(height: 4), - ], - if (poi != null) - _buildPoiMessage(context, poi, textColor, metaColor) - else if (gifId != null) - GifMessage( - url: 'https://media.giphy.com/media/$gifId/giphy.gif', - backgroundColor: bubbleColor, - fallbackTextColor: textColor.withValues(alpha: 0.7), - ) - else - if(!isOutgoing) - - Text( - messageText, - style: TextStyle( - color: textColor, - ), - ), - if(isOutgoing) - Text( - message.text, - style: TextStyle( - color: textColor, - ), - ), - - if (isOutgoing && message.retryCount > 0) ...[ - const SizedBox(height: 4), - Text( - 'Retry ${message.retryCount}/4', - style: TextStyle( - fontSize: 10, - color: metaColor, - fontWeight: FontWeight.w500, - ), - ), - ], - const SizedBox(height: 4), - Wrap( - spacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - _formatTime(message.timestamp), - style: TextStyle( - fontSize: 10, - color: metaColor, - ), - ), - if (isOutgoing) ...[ - const SizedBox(width: 4), - _buildStatusIcon(metaColor), - ], - if (message.tripTimeMs != null && - message.status == MessageStatus.delivered) ...[ - const SizedBox(width: 4), - Icon( - Icons.speed, - size: 10, - color: isOutgoing ? metaColor : Colors.green[700], - ), - Text( - '${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s', - style: TextStyle( - fontSize: 9, - color: isOutgoing ? metaColor : Colors.green[700], - ), - ), - ], - ], - ), - ], - ), - ), - ), - ], - ), - ), - if (message.reactions.isNotEmpty) ...[ - const SizedBox(height: 4), - Padding( - padding: EdgeInsets.only(left: isOutgoing ? 0 : 48), - child: _buildReactionsDisplay(context, message, colorScheme), - ), - ], - ], - ), - ); - } - - String? _parseGifId(String text) { - final trimmed = text.trim(); - final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed); - return match?.group(1); - } - - _PoiInfo? _parsePoiMessage(String text) { - final trimmed = text.trim(); - final match = RegExp(r'^m:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|.*$') - .firstMatch(trimmed); - if (match == null) return null; - final lat = double.tryParse(match.group(1) ?? ''); - final lon = double.tryParse(match.group(2) ?? ''); - if (lat == null || lon == null) return null; - final label = match.group(3) ?? ''; - return _PoiInfo(lat: lat, lon: lon, label: label); - } - - Widget _buildPoiMessage( - BuildContext context, - _PoiInfo poi, - Color textColor, - Color metaColor, - ) { - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IconButton( - icon: Icon(Icons.location_on_outlined, color: textColor), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => MapScreen( - highlightPosition: LatLng(poi.lat, poi.lon), - highlightLabel: poi.label, - ), - ), - ); - }, - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'POI Shared', - style: TextStyle( - color: textColor, - fontWeight: FontWeight.w600, - ), - ), - if (poi.label.isNotEmpty) - Text( - poi.label, - style: TextStyle( - color: metaColor, - fontSize: 12, - ), - ), - ], - ), - ), - ], - ); - } - - Widget _buildReactionsDisplay(BuildContext context, Message message, ColorScheme colorScheme) { - return Wrap( - spacing: 6, - runSpacing: 6, - children: message.reactions.entries.map((entry) { - final emoji = entry.key; - final count = entry.value; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: colorScheme.outline.withValues(alpha: 0.3), - width: 1, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - emoji, - style: const TextStyle(fontSize: 16), - ), - if (count > 1) ...[ - const SizedBox(width: 4), - Text( - '$count', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: colorScheme.onSecondaryContainer, - ), - ), - ], - ], - ), - ); - }).toList(), - ); - } - - Widget _buildAvatar(String senderName, ColorScheme colorScheme) { - final initial = _getFirstCharacterOrEmoji(senderName); - final color = _getColorForName(senderName); - - return CircleAvatar( - radius: 18, - backgroundColor: color.withValues(alpha: 0.2), - child: Text( - initial, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: color, - ), - ), - ); - } - - String _getFirstCharacterOrEmoji(String name) { - if (name.isEmpty) return '?'; - - final emoji = firstEmoji(name); - if (emoji != null) return emoji; - - final runes = name.runes.toList(); - if (runes.isEmpty) return '?'; - return String.fromCharCode(runes[0]).toUpperCase(); - } - - Color _getColorForName(String name) { - // Generate a consistent color based on the name hash - final hash = name.hashCode; - final colors = [ - Colors.blue, - Colors.green, - Colors.orange, - Colors.purple, - Colors.pink, - Colors.teal, - Colors.indigo, - Colors.cyan, - Colors.amber, - Colors.deepOrange, - ]; - - return colors[hash.abs() % colors.length]; - } - - Widget _buildStatusIcon(Color color) { - IconData icon; - switch (message.status) { - case MessageStatus.pending: - icon = Icons.access_time; - break; - case MessageStatus.sent: - icon = Icons.schedule; - break; - case MessageStatus.delivered: - icon = Icons.check; - break; - case MessageStatus.failed: - icon = Icons.error_outline; - break; - } - - return Icon( - icon, - size: 12, - color: color, - ); - } - - String _formatTime(DateTime time) { - final hour = time.hour.toString().padLeft(2, '0'); - final minute = time.minute.toString().padLeft(2, '0'); - return '$hour:$minute'; - } -} - -class _PoiInfo { - final double lat; - final double lon; - final String label; - - const _PoiInfo({ - required this.lat, - required this.lon, - required this.label, - }); -} From 8c3ffa5472b20734620343970d1555d862d95879 Mon Sep 17 00:00:00 2001 From: zjs81 Date: Sun, 11 Jan 2026 11:51:40 -0700 Subject: [PATCH 6/6] Refactor code for improved readability and null safety in various files Also updated PR to allow login via map. --- lib/connector/meshcore_connector.dart | 4 +-- lib/screens/chat_screen.dart | 38 ++++++++++++++------------- lib/screens/contacts_screen.dart | 2 +- lib/screens/map_screen.dart | 28 ++++++++++++++++++++ lib/storage/message_store.dart | 4 ++- 5 files changed, 54 insertions(+), 22 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index a929f19..b22a358 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -1996,14 +1996,14 @@ class MeshCoreConnector extends ChangeNotifier { final settings = _appSettingsService!.settings; if (settings.notificationsEnabled && settings.notifyOnNewMessage) { // Find the contact name - if(contact?.type == advTypeChat) { + if (contact?.type == advTypeChat) { _notificationService.showMessageNotification( contactName: contact?.name ?? 'Unknown', message: message.text, contactId: message.senderKeyHex, badgeCount: getTotalUnreadCount(), ); - }else if(contact?.type == advTypeRoom) { + } else if (contact?.type == advTypeRoom) { _notificationService.showMessageNotification( contactName: contact?.name ?? 'Unknown Room', message: message.text.substring(4), diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index bb1388c..ce4c3e1 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -208,7 +208,7 @@ class _ChatScreenState extends State { Contact contact = widget.contact; final message = messages[index]; String fourByteHex = ''; - if(widget.contact.type == advTypeRoom) { + if (widget.contact.type == advTypeRoom) { contact = _resolveContactFrom4Bytes( connector, message.fourByteRoomContactKey.isEmpty ? Uint8List.fromList([0, 0, 0, 0]) : message.fourByteRoomContactKey, @@ -763,9 +763,18 @@ class _ChatScreenState extends State { void _openMessagePath(Message message, Contact contact) { final connector = context.read(); - final fourByteHex = message.fourByteRoomContactKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase(); - final senderName = - message.isOutgoing ? (connector.selfName ?? 'Me') : widget.contact.type == advTypeRoom ? "${contact.name} [$fourByteHex]" : widget.contact.name; + final fourByteHex = message.fourByteRoomContactKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toUpperCase(); + final String senderName; + if (message.isOutgoing) { + senderName = connector.selfName ?? 'Me'; + } else if (widget.contact.type == advTypeRoom) { + senderName = "${contact.name} [$fourByteHex]"; + } else { + senderName = widget.contact.name; + } final pathMessage = ChannelMessage( senderKey: null, senderName: senderName, @@ -826,11 +835,12 @@ class _ChatScreenState extends State { _retryMessage(message); }, ), - if(widget.contact.type == advTypeRoom) + if (widget.contact.type == advTypeRoom) ListTile( leading: const Icon(Icons.chat), title: const Text('Open Chat'), onTap: () { + Navigator.pop(sheetContext); _openChat(context, contact); }, ), @@ -977,20 +987,12 @@ class _MessageBubble extends StatelessWidget { fallbackTextColor: textColor.withValues(alpha: 0.7), ) else - if(!isOutgoing) - Text( - messageText, - style: TextStyle( - color: textColor, - ), - ), - if(isOutgoing) - Text( - message.text, - style: TextStyle( - color: textColor, - ), + Text( + messageText, + style: TextStyle( + color: textColor, ), + ), if (isOutgoing && message.retryCount > 0) ...[ const SizedBox(height: 4), Text( diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index eed38df..32799eb 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -681,7 +681,7 @@ class _ContactsScreenState extends State _showRepeaterLogin(context, contact); }, ) - else if(isRoom) + else if (isRoom) ListTile( leading: const Icon(Icons.room, color: Colors.blue), title: const Text('Room Login'), diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 93c8988..4dc7971 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -18,6 +18,7 @@ import 'channels_screen.dart'; import 'chat_screen.dart'; import 'contacts_screen.dart'; import '../widgets/repeater_login_dialog.dart'; +import '../widgets/room_login_dialog.dart'; import 'repeater_hub_screen.dart'; import 'settings_screen.dart'; @@ -572,6 +573,25 @@ class _MapScreenState extends State { ); } + void _showRoomLogin(BuildContext context, Contact room) { + showDialog( + context: context, + builder: (context) => RoomLoginDialog( + room: room, + onLogin: (password) { + // Navigate to chat screen after successful login + context.read().markContactRead(room.publicKeyHex); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen(contact: room), + ), + ); + }, + ), + ); + } + void _showNodeInfo(BuildContext context, Contact contact) { showDialog( context: context, @@ -624,6 +644,14 @@ class _MapScreenState extends State { }, child: const Text('Manage Repeater'), ), + if (contact.type == advTypeRoom) + TextButton( + onPressed: () { + Navigator.pop(context); + _showRoomLogin(context, contact); + }, + child: const Text('Join Room'), + ), ], ), ); diff --git a/lib/storage/message_store.dart b/lib/storage/message_store.dart index 8f9e703..c1bc640 100644 --- a/lib/storage/message_store.dart +++ b/lib/storage/message_store.dart @@ -87,7 +87,9 @@ class MessageStore { reactions: (json['reactions'] as Map?)?.map( (key, value) => MapEntry(key, value as int), ) ?? {}, - fourByteRoomContactKey: Uint8List.fromList(base64Decode(json['fourByteRoomContactKey'] as String)), + fourByteRoomContactKey: json['fourByteRoomContactKey'] != null + ? Uint8List.fromList(base64Decode(json['fourByteRoomContactKey'] as String)) + : null, ); } }