mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
1508 lines
52 KiB
Dart
1508 lines
52 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/material.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';
|
|
|
|
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 = ScrollController();
|
|
bool _clearPath = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
context.read<MeshCoreConnector>().setActiveContact(widget.contact.publicKeyHex);
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
context.read<MeshCoreConnector>().setActiveContact(null);
|
|
_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 = 'Unread: $unreadCount';
|
|
final pathLabel = _clearPath ? 'Flood (forced)' : _currentPathLabel(contact);
|
|
final canShowPathDetails = !_clearPath && contact.path.isNotEmpty;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(contact.name),
|
|
if (canShowPathDetails)
|
|
GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onLongPress: () => _showFullPathDialog(context, contact.path),
|
|
child: Text(
|
|
'$pathLabel • $unreadLabel',
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal),
|
|
),
|
|
)
|
|
else
|
|
Text(
|
|
'$pathLabel • $unreadLabel',
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
centerTitle: false,
|
|
actions: [
|
|
PopupMenuButton<String>(
|
|
icon: Icon(_clearPath ? Icons.waves : Icons.route),
|
|
tooltip: 'Routing mode',
|
|
onSelected: (mode) {
|
|
setState(() {
|
|
_clearPath = (mode == 'flood');
|
|
});
|
|
},
|
|
itemBuilder: (context) => [
|
|
PopupMenuItem(
|
|
value: 'auto',
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.auto_mode, size: 20, color: !_clearPath ? Theme.of(context).primaryColor : null),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Auto (use saved path)',
|
|
style: TextStyle(
|
|
fontWeight: !_clearPath ? FontWeight.bold : FontWeight.normal,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
PopupMenuItem(
|
|
value: 'flood',
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.waves, size: 20, color: _clearPath ? Theme.of(context).primaryColor : null),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Force Flood Mode',
|
|
style: TextStyle(
|
|
fontWeight: _clearPath ? FontWeight.bold : FontWeight.normal,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.timeline),
|
|
tooltip: 'Path management',
|
|
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: messages.isEmpty
|
|
? _buildEmptyState()
|
|
: _buildMessageList(messages),
|
|
),
|
|
_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<Message> messages) {
|
|
return ListView.builder(
|
|
controller: _scrollController,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
|
|
itemCount: messages.length,
|
|
itemBuilder: (context, index) {
|
|
final message = messages[index];
|
|
return _MessageBubble(
|
|
message: message,
|
|
senderName: widget.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<TextEditingValue>(
|
|
valueListenable: _textController,
|
|
builder: (context, value, child) {
|
|
final gifId = _parseGifId(value.text);
|
|
if (gifId != null) {
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: GifMessage(
|
|
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
|
backgroundColor: 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,
|
|
clearPath: _clearPath,
|
|
);
|
|
_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<MeshCoreConnector>(context, listen: false);
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => Consumer<PathHistoryService>(
|
|
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;
|
|
|
|
await connector.setContactPath(
|
|
widget.contact,
|
|
pathBytes,
|
|
pathLength,
|
|
);
|
|
|
|
// Update contact in memory directly for immediate UI feedback
|
|
connector.updateContactInMemory(
|
|
widget.contact.publicKeyHex,
|
|
pathBytes: pathBytes,
|
|
pathLength: pathLength,
|
|
);
|
|
|
|
if (!context.mounted) return;
|
|
setState(() {
|
|
_clearPath = false;
|
|
});
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Using ${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'} path'),
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
);
|
|
}),
|
|
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: () {
|
|
setState(() {
|
|
_clearPath = true;
|
|
});
|
|
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<int> 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,
|
|
);
|
|
}
|
|
|
|
String _currentPathLabel(Contact contact) {
|
|
if (contact.pathLength < 0) return 'Flood (auto)';
|
|
if (contact.pathLength == 0) return 'Direct';
|
|
if (contact.pathIdList.isNotEmpty) return contact.pathIdList;
|
|
return '${contact.pathLength} hops';
|
|
}
|
|
|
|
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('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)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showCustomPathDialog(BuildContext context) {
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
|
|
final currentContact = _resolveContact(connector);
|
|
if (currentContact.pathLength > 0 && currentContact.path.isEmpty && connector.isConnected) {
|
|
connector.getContacts();
|
|
}
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Row(
|
|
children: [
|
|
Icon(Icons.edit_road),
|
|
SizedBox(width: 8),
|
|
Text('Set Custom Path'),
|
|
],
|
|
),
|
|
content: Consumer<MeshCoreConnector>(
|
|
builder: (context, connector, _) {
|
|
final contact = _resolveContact(connector);
|
|
final pathForInput = contact.pathIdList;
|
|
final currentPathLabel = _currentPathLabel(contact);
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Text(
|
|
'Current path',
|
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
|
),
|
|
const Spacer(),
|
|
TextButton.icon(
|
|
onPressed: connector.isConnected ? connector.getContacts : null,
|
|
icon: const Icon(Icons.refresh, size: 16),
|
|
label: const Text('Reload'),
|
|
),
|
|
],
|
|
),
|
|
Text(
|
|
currentPathLabel,
|
|
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
|
),
|
|
const SizedBox(height: 16),
|
|
const Text(
|
|
'Choose how to set the message path:',
|
|
style: TextStyle(fontSize: 14),
|
|
),
|
|
const SizedBox(height: 16),
|
|
ListTile(
|
|
dense: true,
|
|
leading: const CircleAvatar(
|
|
radius: 16,
|
|
backgroundColor: Colors.blue,
|
|
child: Icon(Icons.text_fields, size: 16),
|
|
),
|
|
title: const Text('Enter Path Manually', style: TextStyle(fontSize: 14)),
|
|
subtitle: const Text('Type IDs like: A1B2C3D4,FFEEDDCC', style: TextStyle(fontSize: 11)),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_showManualPathInput(
|
|
context,
|
|
initialPath: pathForInput.isEmpty ? null : pathForInput,
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
ListTile(
|
|
dense: true,
|
|
leading: const CircleAvatar(
|
|
radius: 16,
|
|
backgroundColor: Colors.green,
|
|
child: Icon(Icons.contacts, size: 16),
|
|
),
|
|
title: const Text('Select from Contacts', style: TextStyle(fontSize: 14)),
|
|
subtitle: const Text('Pick repeaters/rooms as hops', style: TextStyle(fontSize: 11)),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_showContactPathPicker(context);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showManualPathInput(BuildContext context, {String? initialPath}) {
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
final controller = TextEditingController(text: initialPath ?? '');
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Enter Custom Path'),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Enter 2-character hex prefixes for each hop, separated by commas.',
|
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
|
),
|
|
const SizedBox(height: 8),
|
|
const Text(
|
|
'Example: A1,F2,3C (each node uses first byte of its public key)',
|
|
style: TextStyle(fontSize: 11, color: Colors.grey),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: controller,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Path (hex prefixes)',
|
|
hintText: 'A1,F2,3C',
|
|
border: OutlineInputBorder(),
|
|
helperText: 'Max 64 hops. Each prefix is 2 hex characters (1 byte)',
|
|
),
|
|
textCapitalization: TextCapitalization.characters,
|
|
maxLength: 191, // 64 hops * 2 chars + 63 commas
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
final path = controller.text.trim().toUpperCase();
|
|
if (path.isEmpty) {
|
|
if (context.mounted) Navigator.pop(context);
|
|
return;
|
|
}
|
|
|
|
// Parse comma-separated hex prefixes
|
|
final pathIds = path.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
|
final pathBytesList = <int>[];
|
|
final invalidPrefixes = <String>[];
|
|
|
|
for (final id in pathIds) {
|
|
if (id.length < 2) {
|
|
invalidPrefixes.add(id);
|
|
continue;
|
|
}
|
|
|
|
final prefix = id.substring(0, 2);
|
|
try {
|
|
final byte = int.parse(prefix, radix: 16);
|
|
pathBytesList.add(byte);
|
|
} catch (e) {
|
|
invalidPrefixes.add(id);
|
|
}
|
|
}
|
|
|
|
if (!context.mounted) return;
|
|
|
|
// Show error for invalid prefixes
|
|
if (invalidPrefixes.isNotEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Invalid hex prefixes: ${invalidPrefixes.join(", ")}'),
|
|
duration: const Duration(seconds: 3),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Check max path length (64 hops)
|
|
if (pathBytesList.length > 64) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Path too long. Maximum 64 hops allowed.'),
|
|
duration: Duration(seconds: 3),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathBytesList.isNotEmpty) {
|
|
await connector.setContactPath(
|
|
widget.contact,
|
|
Uint8List.fromList(pathBytesList),
|
|
pathBytesList.length,
|
|
);
|
|
|
|
if (context.mounted) {
|
|
Navigator.pop(context);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Path set: ${pathBytesList.length} ${pathBytesList.length == 1 ? "hop" : "hops"}'),
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
child: const Text('Set Path'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showContactPathPicker(BuildContext context) {
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
final selectedContacts = <Contact>[];
|
|
|
|
// Filter to only repeaters and room servers
|
|
final validContacts = connector.contacts
|
|
.where((c) => (c.type == 2 || c.type == 3) && c != widget.contact)
|
|
.toList();
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => StatefulBuilder(
|
|
builder: (context, setDialogState) => AlertDialog(
|
|
title: const Text('Build Path from Contacts'),
|
|
content: SizedBox(
|
|
width: double.maxFinite,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (validContacts.isEmpty) ...[
|
|
const Icon(Icons.info_outline, size: 48, color: Colors.grey),
|
|
const SizedBox(height: 16),
|
|
const Text(
|
|
'No repeaters or room servers found.',
|
|
style: TextStyle(fontSize: 14),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 8),
|
|
const Text(
|
|
'Custom paths require intermediate hops that can relay messages.',
|
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
] else if (selectedContacts.isNotEmpty) ...[
|
|
const Text(
|
|
'Selected Path:',
|
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: selectedContacts.asMap().entries.map((entry) {
|
|
final idx = entry.key;
|
|
final contact = entry.value;
|
|
return Chip(
|
|
avatar: CircleAvatar(
|
|
child: Text('${idx + 1}'),
|
|
),
|
|
label: Text(contact.name),
|
|
onDeleted: () {
|
|
setDialogState(() {
|
|
selectedContacts.removeAt(idx);
|
|
});
|
|
},
|
|
);
|
|
}).toList(),
|
|
),
|
|
const Divider(),
|
|
] else
|
|
const Text(
|
|
'Tap repeaters/rooms to add them to the path:',
|
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (validContacts.isNotEmpty)
|
|
Flexible(
|
|
child: ListView.builder(
|
|
shrinkWrap: true,
|
|
itemCount: validContacts.length,
|
|
itemBuilder: (context, index) {
|
|
final contact = validContacts[index];
|
|
final isSelected = selectedContacts.contains(contact);
|
|
|
|
return ListTile(
|
|
dense: true,
|
|
leading: CircleAvatar(
|
|
radius: 16,
|
|
backgroundColor: isSelected ? Colors.green : (contact.type == 2 ? Colors.blue : Colors.purple),
|
|
child: Icon(
|
|
contact.type == 2 ? Icons.router : Icons.meeting_room,
|
|
size: 16,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
title: Text(contact.name, style: const TextStyle(fontSize: 14)),
|
|
subtitle: Text(
|
|
'${contact.typeLabel} • ${contact.publicKeyHex.substring(0, 8)}',
|
|
style: const TextStyle(fontSize: 10),
|
|
),
|
|
trailing: isSelected
|
|
? const Icon(Icons.check_circle, color: Colors.green)
|
|
: const Icon(Icons.add_circle_outline),
|
|
onTap: () {
|
|
setDialogState(() {
|
|
if (isSelected) {
|
|
selectedContacts.remove(contact);
|
|
} else {
|
|
selectedContacts.add(contact);
|
|
}
|
|
});
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
if (selectedContacts.isNotEmpty)
|
|
TextButton(
|
|
onPressed: () {
|
|
setDialogState(() {
|
|
selectedContacts.clear();
|
|
});
|
|
},
|
|
child: const Text('Clear'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: selectedContacts.isEmpty
|
|
? null
|
|
: () async {
|
|
// Build path bytes from selected contacts (prefix byte of each pub key)
|
|
final pathBytesList = <int>[];
|
|
for (final contact in selectedContacts) {
|
|
if (contact.publicKeyHex.length >= 2) {
|
|
try {
|
|
pathBytesList.add(int.parse(contact.publicKeyHex.substring(0, 2), radix: 16));
|
|
} catch (e) {
|
|
// Skip invalid hex
|
|
}
|
|
}
|
|
}
|
|
|
|
if (pathBytesList.isNotEmpty) {
|
|
await connector.setContactPath(
|
|
widget.contact,
|
|
Uint8List.fromList(pathBytesList),
|
|
pathBytesList.length,
|
|
);
|
|
|
|
final pathIds = selectedContacts
|
|
.map((c) => c.publicKeyHex.substring(0, 8))
|
|
.join(',');
|
|
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Custom path set: $pathIds'),
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
Navigator.pop(context);
|
|
}
|
|
}
|
|
},
|
|
child: const Text('Set Path'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openMessagePath(Message message) {
|
|
final connector = context.read<MeshCoreConnector>();
|
|
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<void> _deleteMessage(Message message) async {
|
|
await context.read<MeshCoreConnector>().deleteMessage(message);
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Message deleted')),
|
|
);
|
|
}
|
|
|
|
void _retryMessage(Message message) {
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
// Retry with clearPath if the message has no path or pathLength is -1 (indicating flood was used)
|
|
final shouldClearPath = message.pathLength != null && message.pathLength! < 0;
|
|
connector.sendMessage(
|
|
widget.contact,
|
|
message.text,
|
|
clearPath: shouldClearPath,
|
|
);
|
|
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<MeshCoreConnector>();
|
|
final reactionText = 'r:${message.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 attempts = message.retryCount + 1;
|
|
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);
|
|
|
|
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
|
|
Text(
|
|
message.text,
|
|
style: TextStyle(
|
|
color: textColor,
|
|
),
|
|
),
|
|
if (isOutgoing) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Attempts: $attempts',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: metaColor,
|
|
),
|
|
),
|
|
],
|
|
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,
|
|
});
|
|
}
|