mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
* Refactor Cayenne LPP parsing with error handling and logging - Added error handling and logging to the Cayenne LPP parsing methods to manage malformed data gracefully. - Improved the structure of the parsing logic for better readability and maintainability. - Updated the Contact model to include error handling during frame parsing. - Refactored Channels, Contacts, Map, and Neighbours screens to utilize a new AppBarTitle widget for consistent app bar design. - Enhanced the BatteryIndicator widget to display SNR information for direct repeaters. - Introduced SNRUi class for better management of SNR icon and text representation. - Improved error handling in PathTraceMap and Neighbours screens to log errors appropriately. * Fix trace route bytes generation logic in Contact model * Ignore advertisements from self in MeshCoreConnector * Refactor PathTraceData to use List<double> for snrData and adjust data mapping in PathTraceMapScreen * Add SNRIndicator to AppBar and refactor BatteryIndicator layout * Enhance path management dialog to display direct repeaters with color coding based on signal strength * Remove unused import from SNR indicator widget * Update lib/models/contact.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update lib/connector/meshcore_connector.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update lib/connector/meshcore_connector.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update lib/screens/path_trace_map.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update lib/widgets/battery_indicator.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update lib/helpers/cayenne_lpp.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactor packet handling to skip only the RSSI byte for improved reliability * Add SNR indicator localization and update UI references for nearby repeaters * Handle loading state and error parsing in PathTraceMapScreen; update SNR indicator dialog content layout * Throw an exception for unsupported LPP types in CayenneLpp class * Refactor AppBarTitle widget to remove unused style parameter; update related screens to reflect changes Improve SNR handling by adding validation for spreading factor range in snrUiFromSNR function Update contact handling in MeshCoreConnector to fix variable naming and improve readability Stop parsing unsupported LPP types in CayenneLpp to avoid misalignment * Sort direct repeaters by last updated time and SNR; limit to top three for improved path management dialog * Prevent notifications for chat and sensor adverts without a valid path * Implement ranking system for direct repeaters based on SNR and recency; update related UI components to reflect changes * Refactor localization keys for "neighbors" terminology across multiple languages - Updated localization keys from "neighbours" to "neighbors" in the following files: - app_localizations_bg.dart - app_localizations_de.dart - app_localizations_en.dart - app_localizations_es.dart - app_localizations_fr.dart - app_localizations_it.dart - app_localizations_nl.dart - app_localizations_pl.dart - app_localizations_pt.dart - app_localizations_ru.dart - app_localizations_sk.dart - app_localizations_sl.dart - app_localizations_sv.dart - app_localizations_uk.dart - app_localizations_zh.dart - Updated corresponding ARB files to reflect the changes in keys. - Renamed the NeighboursScreen to NeighborsScreen in the chat and repeater hub screens for consistency. * Adjust ranking calculation for direct repeaters by adding offset to SNR for improved accuracy * Fix typo in variable name for second direct repeater in path management dialog * Refactor ranking calculation for direct repeaters and update path handling in channel message screens * Refactor path handling in ChannelMessagePathScreen to improve logic for outgoing messages and channel messages * Fix AppBarTitle horizontal overflow with long titles (#187) * Initial plan * Wrap title Column in Expanded to prevent horizontal overflow in AppBarTitle Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com> * Refactor AppBarTitle widget to simplify Text widget initialization * Add "Show All Paths" feature to chat path management - Implemented localization for "Show All Paths" in multiple languages (DE, EN, ES, FR, IT, NL, PL, PT, RU, SK, SL, SV, UK, ZH). - Updated path management dialog to include a toggle for showing all paths. - Refactored path history display logic to conditionally show paths based on the toggle state. - Cleaned up unused print statements and improved code readability in path tracing and chat screens. * Refactor FeatureToggleRow visibility in chat and path management dialogs based on repeaters list * Remove unused import of 'dart:ffi' in path_trace_map.dart * Refactor repeater management logic and update UI state handling in chat and path management dialogs * Refactor RX data handling and improve repeater management logic in chat and path management dialogs --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com>
1557 lines
55 KiB
Dart
1557 lines
55 KiB
Dart
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:flutter_linkify/flutter_linkify.dart';
|
|
import 'package:meshcore_open/screens/path_trace_map.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
|
|
import '../connector/meshcore_connector.dart';
|
|
import '../connector/meshcore_protocol.dart';
|
|
import '../helpers/reaction_helper.dart';
|
|
import '../helpers/chat_scroll_controller.dart';
|
|
import '../helpers/link_handler.dart';
|
|
import '../helpers/utf8_length_limiter.dart';
|
|
import '../models/channel_message.dart';
|
|
import '../models/contact.dart';
|
|
import '../models/message.dart';
|
|
import '../models/path_history.dart';
|
|
import '../services/path_history_service.dart';
|
|
import '../widgets/elements_ui.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/jump_to_bottom_button.dart';
|
|
import '../widgets/gif_picker.dart';
|
|
import '../widgets/path_selection_dialog.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../l10n/l10n.dart';
|
|
|
|
class ChatScreen extends StatefulWidget {
|
|
final Contact contact;
|
|
|
|
const ChatScreen({super.key, required this.contact});
|
|
|
|
@override
|
|
State<ChatScreen> createState() => _ChatScreenState();
|
|
}
|
|
|
|
class _ChatScreenState extends State<ChatScreen> {
|
|
final _textController = TextEditingController();
|
|
final _scrollController = ChatScrollController();
|
|
final _textFieldFocusNode = FocusNode();
|
|
bool _isLoadingOlder = false;
|
|
MeshCoreConnector? _connector;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
|
|
_scrollController.onScrollNearTop = _loadOlderMessages;
|
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
_connector = context.read<MeshCoreConnector>();
|
|
_connector?.setActiveContact(widget.contact.publicKeyHex);
|
|
});
|
|
}
|
|
|
|
void _onTextFieldFocusChange() {
|
|
if (_textFieldFocusNode.hasFocus && mounted) {
|
|
_scrollController.handleKeyboardOpen();
|
|
}
|
|
}
|
|
|
|
Future<void> _loadOlderMessages() async {
|
|
if (_isLoadingOlder) return;
|
|
setState(() => _isLoadingOlder = true);
|
|
|
|
final connector = context.read<MeshCoreConnector>();
|
|
await connector.loadOlderMessages(widget.contact.publicKeyHex);
|
|
|
|
if (mounted) {
|
|
setState(() => _isLoadingOlder = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_connector?.setActiveContact(null);
|
|
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
|
|
_textFieldFocusNode.dispose();
|
|
_textController.dispose();
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Consumer2<PathHistoryService, MeshCoreConnector>(
|
|
builder: (context, pathService, connector, _) {
|
|
final contact = _resolveContact(connector);
|
|
final unreadCount = connector.getUnreadCountForContactKey(
|
|
widget.contact.publicKeyHex,
|
|
);
|
|
final unreadLabel = context.l10n.chat_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<MeshCoreConnector>(
|
|
builder: (context, connector, _) {
|
|
final contact = _resolveContact(connector);
|
|
final isFloodMode = contact.pathOverride == -1;
|
|
|
|
return PopupMenuButton<String>(
|
|
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
|
tooltip: context.l10n.chat_routingMode,
|
|
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(
|
|
context.l10n.chat_autoUseSavedPath,
|
|
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(
|
|
context.l10n.chat_forceFloodMode,
|
|
style: TextStyle(
|
|
fontWeight: isFloodMode
|
|
? FontWeight.bold
|
|
: FontWeight.normal,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.timeline),
|
|
tooltip: context.l10n.chat_pathManagement,
|
|
onPressed: () => _showPathHistory(context),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.info_outline),
|
|
onPressed: () => _showContactInfo(context),
|
|
),
|
|
],
|
|
),
|
|
body: Consumer<MeshCoreConnector>(
|
|
builder: (context, connector, child) {
|
|
final messages = connector.getMessages(widget.contact);
|
|
return Column(
|
|
children: [
|
|
Expanded(
|
|
child: Stack(
|
|
children: [
|
|
messages.isEmpty
|
|
? _buildEmptyState()
|
|
: _buildMessageList(messages, connector),
|
|
JumpToBottomButton(scrollController: _scrollController),
|
|
],
|
|
),
|
|
),
|
|
_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(
|
|
context.l10n.chat_noMessages,
|
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
context.l10n.chat_sendMessageTo(widget.contact.name),
|
|
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMessageList(
|
|
List<Message> messages,
|
|
MeshCoreConnector connector,
|
|
) {
|
|
// Reverse messages so newest appear at bottom with reverse: true
|
|
final reversedMessages = messages.reversed.toList();
|
|
final itemCount = reversedMessages.length + (_isLoadingOlder ? 1 : 0);
|
|
|
|
// Auto-scroll to bottom if user is already at bottom
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_scrollController.scrollToBottomIfAtBottom();
|
|
});
|
|
|
|
return ListView.builder(
|
|
reverse: true, // List grows from bottom up
|
|
controller: _scrollController,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
|
|
itemCount: itemCount,
|
|
itemBuilder: (context, index) {
|
|
// Loading indicator now appears at end (bottom) of reversed list
|
|
if (_isLoadingOlder && index == itemCount - 1) {
|
|
return const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 16),
|
|
child: Center(
|
|
child: SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
final messageIndex = index;
|
|
Contact contact = widget.contact;
|
|
final message = reversedMessages[messageIndex];
|
|
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.type == advTypeRoom
|
|
? "${contact.name} [$fourByteHex]"
|
|
: contact.name,
|
|
isRoomServer: widget.contact.type == advTypeRoom,
|
|
onTap: () => _openMessagePath(message, contact),
|
|
onLongPress: () => _showMessageActions(message, contact),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
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: context.l10n.chat_sendGif,
|
|
),
|
|
Expanded(
|
|
child: ValueListenableBuilder<TextEditingValue>(
|
|
valueListenable: _textController,
|
|
builder: (context, value, child) {
|
|
final gifId = _parseGifId(value.text);
|
|
if (gifId != null) {
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: GifMessage(
|
|
url:
|
|
'https://media.giphy.com/media/$gifId/giphy.gif',
|
|
backgroundColor:
|
|
colorScheme.surfaceContainerHighest,
|
|
fallbackTextColor: colorScheme.onSurface
|
|
.withValues(alpha: 0.6),
|
|
maxSize: 160,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () => _textController.clear(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
return TextField(
|
|
controller: _textController,
|
|
focusNode: _textFieldFocusNode,
|
|
inputFormatters: [
|
|
Utf8LengthLimitingTextInputFormatter(maxBytes),
|
|
],
|
|
textCapitalization: TextCapitalization.sentences,
|
|
decoration: InputDecoration(
|
|
hintText: context.l10n.chat_typeMessage,
|
|
border: const OutlineInputBorder(),
|
|
contentPadding: const 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(context.l10n.chat_messageTooLong(maxBytes))),
|
|
);
|
|
return;
|
|
}
|
|
|
|
connector.sendMessage(widget.contact, text);
|
|
_textController.clear();
|
|
}
|
|
|
|
void _showPathHistory(BuildContext context) {
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
bool showAllPaths = false;
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => StatefulBuilder(
|
|
builder: (context, setDialogState) => Consumer<PathHistoryService>(
|
|
builder: (context, pathService, _) {
|
|
final paths = pathService.getRecentPaths(
|
|
widget.contact.publicKeyHex,
|
|
);
|
|
|
|
final repeatersList = List.of(connector.directRepeaters)
|
|
..sort((a, b) => b.ranking.compareTo(a.ranking));
|
|
|
|
if (repeatersList.isEmpty) {
|
|
showAllPaths = true;
|
|
}
|
|
|
|
final directRepeater = repeatersList.isEmpty
|
|
? null
|
|
: repeatersList.first;
|
|
final secondDirectRepeater = repeatersList.length < 2
|
|
? null
|
|
: repeatersList.elementAt(1);
|
|
final thirdDirectRepeater = repeatersList.length < 3
|
|
? null
|
|
: repeatersList.elementAt(2);
|
|
|
|
List<MapEntry<int, MapEntry<Color, PathRecord>>>
|
|
pathsWithRepeaters = paths.map((path) {
|
|
final isDirectRepeater =
|
|
directRepeater != null &&
|
|
path.pathBytes.isNotEmpty &&
|
|
directRepeater.pubkeyFirstByte == path.pathBytes.first;
|
|
final isSecondDirectRepeater =
|
|
secondDirectRepeater != null &&
|
|
path.pathBytes.isNotEmpty &&
|
|
secondDirectRepeater.pubkeyFirstByte == path.pathBytes.first;
|
|
final isThirdDirectRepeater =
|
|
thirdDirectRepeater != null &&
|
|
path.pathBytes.isNotEmpty &&
|
|
thirdDirectRepeater.pubkeyFirstByte == path.pathBytes.first;
|
|
|
|
int ranking = -1;
|
|
Color color = Colors.grey;
|
|
if (isDirectRepeater) {
|
|
color = Colors.green;
|
|
ranking = 3;
|
|
} else if (isSecondDirectRepeater) {
|
|
color = Colors.yellow;
|
|
ranking = 2;
|
|
} else if (isThirdDirectRepeater) {
|
|
color = Colors.red;
|
|
ranking = 1;
|
|
} else if (path.wasFloodDiscovery) {
|
|
color = Colors.blue;
|
|
ranking = 0;
|
|
}
|
|
|
|
return MapEntry(ranking, MapEntry(color, path));
|
|
}).toList();
|
|
|
|
pathsWithRepeaters.sort((a, b) => b.key.compareTo(a.key));
|
|
|
|
return AlertDialog(
|
|
title: Row(
|
|
children: [
|
|
const Icon(Icons.timeline),
|
|
const SizedBox(width: 8),
|
|
Text(context.l10n.chat_pathManagement),
|
|
],
|
|
),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (pathsWithRepeaters.isNotEmpty) ...[
|
|
if (repeatersList.isNotEmpty)
|
|
FeatureToggleRow(
|
|
title: context.l10n.chat_ShowAllPaths,
|
|
subtitle: "",
|
|
value: showAllPaths,
|
|
onChanged: (val) {
|
|
setDialogState(() {
|
|
showAllPaths = val;
|
|
});
|
|
},
|
|
),
|
|
Text(
|
|
context.l10n.chat_recentAckPaths,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
if (pathsWithRepeaters.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: Text(
|
|
context.l10n.chat_pathHistoryFull,
|
|
style: const TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 8),
|
|
...pathsWithRepeaters.map((entry) {
|
|
final path = entry.value.value;
|
|
final color = entry.value.key;
|
|
if (!showAllPaths && entry.key < 1) {
|
|
return const SizedBox.shrink();
|
|
} else {
|
|
return Card(
|
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
|
child: ListTile(
|
|
dense: true,
|
|
leading: CircleAvatar(
|
|
radius: 16,
|
|
backgroundColor: color,
|
|
child: Text(
|
|
'${path.hopCount}',
|
|
style: const TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
title: Text(
|
|
'${path.hopCount} ${path.hopCount == 1 ? context.l10n.chat_hopSingular : context.l10n.chat_hopPlural}',
|
|
style: const TextStyle(fontSize: 14),
|
|
),
|
|
subtitle: Text(
|
|
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)} • ${path.successCount} ${context.l10n.chat_successes}',
|
|
style: const TextStyle(fontSize: 11),
|
|
),
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.close, size: 16),
|
|
tooltip: context.l10n.chat_removePath,
|
|
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(
|
|
SnackBar(
|
|
content: Text(
|
|
context
|
|
.l10n
|
|
.chat_pathDetailsNotAvailable,
|
|
),
|
|
duration: const 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 ...[
|
|
Text(context.l10n.chat_noPathHistoryYet),
|
|
const Divider(),
|
|
],
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
context.l10n.chat_pathActions,
|
|
style: const 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: Text(
|
|
context.l10n.chat_setCustomPath,
|
|
style: const TextStyle(fontSize: 14),
|
|
),
|
|
subtitle: Text(
|
|
context.l10n.chat_setCustomPathSubtitle,
|
|
style: const 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: Text(
|
|
context.l10n.chat_clearPath,
|
|
style: const TextStyle(fontSize: 14),
|
|
),
|
|
subtitle: Text(
|
|
context.l10n.chat_clearPathSubtitle,
|
|
style: const TextStyle(fontSize: 11),
|
|
),
|
|
onTap: () async {
|
|
await connector.clearContactPath(widget.contact);
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(context.l10n.chat_pathCleared),
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
ListTile(
|
|
dense: true,
|
|
leading: const CircleAvatar(
|
|
radius: 16,
|
|
backgroundColor: Colors.blue,
|
|
child: Icon(Icons.waves, size: 16),
|
|
),
|
|
title: Text(
|
|
context.l10n.chat_forceFloodMode,
|
|
style: const TextStyle(fontSize: 14),
|
|
),
|
|
subtitle: Text(
|
|
context.l10n.chat_floodModeSubtitle,
|
|
style: const TextStyle(fontSize: 11),
|
|
),
|
|
onTap: () async {
|
|
await connector.setPathOverride(
|
|
widget.contact,
|
|
pathLen: -1,
|
|
);
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(context.l10n.chat_floodModeEnabled),
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(context.l10n.common_close),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatRelativeTime(DateTime time) {
|
|
final diff = DateTime.now().difference(time);
|
|
if (diff.inSeconds < 60) return context.l10n.time_justNow;
|
|
if (diff.inMinutes < 60) {
|
|
return context.l10n.time_minutesAgo(diff.inMinutes);
|
|
}
|
|
if (diff.inHours < 24) return context.l10n.time_hoursAgo(diff.inHours);
|
|
return context.l10n.time_daysAgo(diff.inDays);
|
|
}
|
|
|
|
void _showFullPathDialog(BuildContext context, List<int> pathBytes) {
|
|
if (pathBytes.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(context.l10n.chat_pathDetailsNotAvailable),
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final formattedPath = pathBytes
|
|
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
|
.join(',');
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(context.l10n.chat_fullPath),
|
|
content: SelectableText(formattedPath),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => PathTraceMapScreen(
|
|
title: context.l10n.contacts_repeaterPathTrace,
|
|
path: Uint8List.fromList(pathBytes),
|
|
flipPathRound: true,
|
|
),
|
|
),
|
|
),
|
|
child: Text(context.l10n.contacts_pathTrace),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(context.l10n.common_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 context.l10n.chat_floodForced;
|
|
if (contact.pathOverride == 0) return context.l10n.chat_directForced;
|
|
return context.l10n.chat_hopsForced(contact.pathOverride!);
|
|
}
|
|
|
|
// Use device's path
|
|
if (contact.pathLength < 0) return context.l10n.chat_floodAuto;
|
|
if (contact.pathLength == 0) return context.l10n.chat_direct;
|
|
return context.l10n.chat_hopsCount(contact.pathLength);
|
|
}
|
|
|
|
Future<void> _notifyPathSet(
|
|
MeshCoreConnector connector,
|
|
Contact contact,
|
|
Uint8List pathBytes,
|
|
int hopCount,
|
|
) async {
|
|
final verified = connector.isConnected
|
|
? await connector.verifyContactPathOnDevice(contact, pathBytes)
|
|
: false;
|
|
if (!mounted) return;
|
|
|
|
final status = !connector.isConnected
|
|
? context.l10n.chat_pathSavedLocally
|
|
: (verified
|
|
? context.l10n.chat_pathDeviceConfirmed
|
|
: context.l10n.chat_pathDeviceNotConfirmed);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(context.l10n.chat_pathSetHops(hopCount, status)),
|
|
duration: const Duration(seconds: 3),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showContactInfo(BuildContext context) {
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex);
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => Consumer<MeshCoreConnector>(
|
|
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(context.l10n.chat_type, contact.typeLabel),
|
|
_buildInfoRow(context.l10n.chat_path, contact.pathLabel),
|
|
if (contact.hasLocation)
|
|
_buildInfoRow(
|
|
context.l10n.chat_location,
|
|
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
|
|
),
|
|
_buildInfoRow(
|
|
context.l10n.chat_publicKey,
|
|
'${contact.publicKeyHex.substring(0, 16)}...',
|
|
),
|
|
const Divider(),
|
|
SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(context.l10n.channels_smazCompression),
|
|
subtitle: Text(context.l10n.chat_compressOutgoingMessages),
|
|
value: smazEnabled,
|
|
onChanged: (value) {
|
|
connector.setContactSmazEnabled(
|
|
contact.publicKeyHex,
|
|
value,
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(context.l10n.common_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)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openChat(BuildContext context, Contact contact) {
|
|
// Check if this is a repeater
|
|
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)),
|
|
);
|
|
}
|
|
|
|
Future<void> _showCustomPathDialog(BuildContext context) async {
|
|
final connector = Provider.of<MeshCoreConnector>(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: context.l10n.chat_setCustomPath,
|
|
currentPathLabel: currentPathLabel,
|
|
onRefresh: connector.isConnected ? connector.getContacts : null,
|
|
);
|
|
|
|
appLogger.info(
|
|
'PathSelectionDialog returned: ${result?.length ?? 0} bytes, mounted: $mounted',
|
|
tag: 'ChatScreen',
|
|
);
|
|
|
|
if (result == null) {
|
|
appLogger.info(
|
|
'PathSelectionDialog was cancelled or returned null',
|
|
tag: 'ChatScreen',
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!mounted) {
|
|
appLogger.warn(
|
|
'Widget not mounted after dialog, cannot set path',
|
|
tag: 'ChatScreen',
|
|
);
|
|
return;
|
|
}
|
|
|
|
appLogger.info(
|
|
'Calling setPathOverride for ${widget.contact.name}',
|
|
tag: 'ChatScreen',
|
|
);
|
|
await connector.setPathOverride(
|
|
widget.contact,
|
|
pathLen: result.length,
|
|
pathBytes: result,
|
|
);
|
|
appLogger.info('setPathOverride completed', tag: 'ChatScreen');
|
|
|
|
if (!mounted) return;
|
|
await _notifyPathSet(connector, widget.contact, result, result.length);
|
|
}
|
|
|
|
void _openMessagePath(Message message, Contact contact) {
|
|
final connector = context.read<MeshCoreConnector>();
|
|
final fourByteHex = message.fourByteRoomContactKey
|
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
|
.join()
|
|
.toUpperCase();
|
|
final String senderName;
|
|
if (message.isOutgoing) {
|
|
senderName = connector.selfName ?? context.l10n.chat_me;
|
|
} else if (widget.contact.type == advTypeRoom) {
|
|
senderName = "${contact.name} [$fourByteHex]";
|
|
} else {
|
|
senderName = 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, Contact contact) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
builder: (sheetContext) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Can't react to your own messages
|
|
if (!message.isOutgoing)
|
|
ListTile(
|
|
leading: const Icon(Icons.add_reaction_outlined),
|
|
title: Text(context.l10n.chat_addReaction),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
_showEmojiPicker(message, contact);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.copy),
|
|
title: Text(context.l10n.common_copy),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
_copyMessageText(message.text);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.delete_outline),
|
|
title: Text(context.l10n.common_delete),
|
|
onTap: () async {
|
|
Navigator.pop(sheetContext);
|
|
await _deleteMessage(message);
|
|
},
|
|
),
|
|
if (message.isOutgoing && message.status == MessageStatus.failed)
|
|
ListTile(
|
|
leading: const Icon(Icons.refresh),
|
|
title: Text(context.l10n.common_retry),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
_retryMessage(message);
|
|
},
|
|
),
|
|
if (widget.contact.type == advTypeRoom)
|
|
ListTile(
|
|
leading: const Icon(Icons.chat),
|
|
title: Text(context.l10n.contacts_openChat),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
_openChat(context, contact);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.close),
|
|
title: Text(context.l10n.common_cancel),
|
|
onTap: () => Navigator.pop(sheetContext),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _copyMessageText(String text) {
|
|
Clipboard.setData(ClipboardData(text: text));
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageCopied)));
|
|
}
|
|
|
|
Future<void> _deleteMessage(Message message) async {
|
|
await context.read<MeshCoreConnector>().deleteMessage(message);
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageDeleted)));
|
|
}
|
|
|
|
void _retryMessage(Message message) {
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
// Retry using the contact's current path override setting
|
|
connector.sendMessage(widget.contact, message.text);
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
|
|
}
|
|
|
|
void _showEmojiPicker(Message message, Contact senderContact) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (context) => EmojiPicker(
|
|
onEmojiSelected: (emoji) {
|
|
_sendReaction(message, senderContact, emoji);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _sendReaction(Message message, Contact senderContact, String emoji) {
|
|
final connector = context.read<MeshCoreConnector>();
|
|
final emojiIndex = ReactionHelper.emojiToIndex(emoji);
|
|
if (emojiIndex == null) return; // Unknown emoji, skip
|
|
final timestampSecs = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
|
|
|
// For room servers, include sender name (like channels) since multiple users
|
|
// For 1:1 chats, sender is implicit (null)
|
|
final senderName = widget.contact.type == advTypeRoom
|
|
? senderContact.name
|
|
: null;
|
|
final hash = ReactionHelper.computeReactionHash(
|
|
timestampSecs,
|
|
senderName,
|
|
message.text,
|
|
);
|
|
final reactionText = 'r:$hash:$emojiIndex';
|
|
connector.sendMessage(widget.contact, reactionText);
|
|
}
|
|
}
|
|
|
|
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,
|
|
});
|
|
|
|
@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);
|
|
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(
|
|
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: gifId != null
|
|
? const EdgeInsets.all(4)
|
|
: 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) ...[
|
|
Padding(
|
|
padding: gifId != null
|
|
? const EdgeInsets.only(
|
|
left: 8,
|
|
top: 4,
|
|
bottom: 4,
|
|
)
|
|
: EdgeInsets.zero,
|
|
child: Text(
|
|
senderName,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.primary,
|
|
),
|
|
),
|
|
),
|
|
if (gifId == null) const SizedBox(height: 4),
|
|
],
|
|
if (poi != null)
|
|
_buildPoiMessage(context, poi, textColor, metaColor)
|
|
else if (gifId != null)
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: GifMessage(
|
|
url:
|
|
'https://media.giphy.com/media/$gifId/giphy.gif',
|
|
backgroundColor: Colors.transparent,
|
|
fallbackTextColor: textColor.withValues(
|
|
alpha: 0.7,
|
|
),
|
|
),
|
|
)
|
|
else
|
|
Linkify(
|
|
text: messageText,
|
|
style: TextStyle(color: textColor),
|
|
linkStyle: const TextStyle(
|
|
color: Colors.green,
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
options: const LinkifyOptions(
|
|
humanize: false,
|
|
defaultToHttps: false,
|
|
),
|
|
linkifiers: const [UrlLinkifier()],
|
|
onOpen: (link) =>
|
|
LinkHandler.handleLinkTap(context, link.url),
|
|
),
|
|
if (isOutgoing && message.retryCount > 0) ...[
|
|
const SizedBox(height: 4),
|
|
Padding(
|
|
padding: gifId != null
|
|
? const EdgeInsets.symmetric(horizontal: 8)
|
|
: EdgeInsets.zero,
|
|
child: Text(
|
|
context.l10n.chat_retryCount(
|
|
message.retryCount,
|
|
4,
|
|
),
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: metaColor,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 4),
|
|
Padding(
|
|
padding: gifId != null
|
|
? const EdgeInsets.only(
|
|
left: 8,
|
|
right: 8,
|
|
bottom: 4,
|
|
)
|
|
: EdgeInsets.zero,
|
|
child: 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(
|
|
context.l10n.chat_poiShared,
|
|
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});
|
|
}
|