mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
Core Features Unread Message Tracking: Added persistent unread counts for contacts and channels with visual badges Message Deletion: Users can now long-press to delete individual messages in chats and channels SMAZ Compression: Added per-contact compression settings (previously only channels) UTF-8 Length Limiting: Text inputs now enforce protocol byte limits correctly Channel Message Paths: New screen to visualize packet routing through repeater network with map view Protocol Updates Added maxContactMessageBytes() and maxChannelMessageBytes() helpers for message length validation Changed channel PSK format from Base64 to Hexadecimal (breaking change) Added app version field to connection handshake frame UI Improvements Unread badges on all contact and channel list items Enhanced message bubbles with path visualization for channel messages Character count displays in message input fields Improved repeater CLI screen functionality New Files lib/storage/unread_store.dart - Unread tracking persistence lib/storage/contact_settings_store.dart - Per-contact SMAZ settings lib/widgets/unread_badge.dart - Unread count indicator lib/helpers/utf8_length_limiter.dart - Byte-aware text input formatter lib/screens/channel_message_path_screen.dart - Packet path visualization
595 lines
19 KiB
Dart
595 lines
19 KiB
Dart
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../connector/meshcore_connector.dart';
|
|
import '../connector/meshcore_protocol.dart';
|
|
import '../helpers/utf8_length_limiter.dart';
|
|
import '../models/channel.dart';
|
|
import '../models/channel_message.dart';
|
|
import '../utils/emoji_utils.dart';
|
|
import '../widgets/gif_message.dart';
|
|
import '../widgets/gif_picker.dart';
|
|
import 'channel_message_path_screen.dart';
|
|
import 'map_screen.dart';
|
|
|
|
class ChannelChatScreen extends StatefulWidget {
|
|
final Channel channel;
|
|
|
|
const ChannelChatScreen({
|
|
super.key,
|
|
required this.channel,
|
|
});
|
|
|
|
@override
|
|
State<ChannelChatScreen> createState() => _ChannelChatScreenState();
|
|
}
|
|
|
|
class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|
final TextEditingController _textController = TextEditingController();
|
|
final ScrollController _scrollController = ScrollController();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
context.read<MeshCoreConnector>().setActiveChannel(widget.channel.index);
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
context.read<MeshCoreConnector>().setActiveChannel(null);
|
|
_textController.dispose();
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _scrollToBottom() {
|
|
if (_scrollController.hasClients) {
|
|
_scrollController.animateTo(
|
|
_scrollController.position.maxScrollExtent,
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Row(
|
|
children: [
|
|
Icon(
|
|
widget.channel.isPublicChannel ? Icons.public : Icons.tag,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
widget.channel.name.isEmpty
|
|
? 'Channel ${widget.channel.index}'
|
|
: widget.channel.name,
|
|
style: const TextStyle(fontSize: 16),
|
|
),
|
|
Consumer<MeshCoreConnector>(
|
|
builder: (context, connector, _) {
|
|
final unreadCount =
|
|
connector.getUnreadCountForChannelIndex(widget.channel.index);
|
|
final privacy = widget.channel.isPublicChannel ? 'Public' : 'Private';
|
|
return Text(
|
|
'$privacy • Unread: $unreadCount',
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontSize: 12),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
centerTitle: false,
|
|
),
|
|
body: Column(
|
|
children: [
|
|
Expanded(
|
|
child: Consumer<MeshCoreConnector>(
|
|
builder: (context, connector, child) {
|
|
final messages = connector.getChannelMessages(widget.channel);
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_scrollToBottom();
|
|
});
|
|
|
|
if (messages.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
widget.channel.isPublicChannel
|
|
? Icons.public
|
|
: Icons.tag,
|
|
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 get started',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: Colors.grey[500],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return ListView.builder(
|
|
controller: _scrollController,
|
|
padding: const EdgeInsets.all(8),
|
|
cacheExtent: 0,
|
|
addAutomaticKeepAlives: false,
|
|
itemCount: messages.length,
|
|
itemBuilder: (context, index) {
|
|
final message = messages[index];
|
|
return _buildMessageBubble(message);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
_buildMessageComposer(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMessageBubble(ChannelMessage message) {
|
|
final isOutgoing = message.isOutgoing;
|
|
final gifId = _parseGifId(message.text);
|
|
final poi = _parsePoiMessage(message.text);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
|
child: Row(
|
|
mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (!isOutgoing) ...[
|
|
_buildAvatar(message.senderName),
|
|
const SizedBox(width: 8),
|
|
],
|
|
Flexible(
|
|
child: GestureDetector(
|
|
onTap: () => _showMessagePathInfo(message),
|
|
onLongPress: () => _showMessageActions(message),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
constraints: BoxConstraints(
|
|
maxWidth: MediaQuery.of(context).size.width * 0.65,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: isOutgoing
|
|
? Theme.of(context).colorScheme.primaryContainer
|
|
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (!isOutgoing) ...[
|
|
Text(
|
|
message.senderName,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
],
|
|
if (poi != null)
|
|
_buildPoiMessage(context, poi, isOutgoing)
|
|
else if (gifId != null)
|
|
GifMessage(
|
|
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
|
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
fallbackTextColor: isOutgoing
|
|
? Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.7)
|
|
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
|
)
|
|
else
|
|
Text(
|
|
message.text,
|
|
style: const TextStyle(fontSize: 14),
|
|
),
|
|
if (message.pathBytes.isNotEmpty) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'via ${_formatPathPrefixes(message.pathBytes)}',
|
|
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
|
),
|
|
],
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
_formatTime(message.timestamp),
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
if (message.repeatCount > 0) ...[
|
|
const SizedBox(width: 6),
|
|
Icon(Icons.repeat, size: 12, color: Colors.grey[600]),
|
|
const SizedBox(width: 2),
|
|
Text(
|
|
'${message.repeatCount}',
|
|
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
|
),
|
|
],
|
|
if (isOutgoing) ...[
|
|
const SizedBox(width: 4),
|
|
Icon(
|
|
message.status == ChannelMessageStatus.sent
|
|
? Icons.check
|
|
: message.status == ChannelMessageStatus.pending
|
|
? Icons.schedule
|
|
: Icons.error_outline,
|
|
size: 14,
|
|
color: message.status == ChannelMessageStatus.failed
|
|
? Colors.red
|
|
: Colors.grey[600],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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, bool isOutgoing) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final textColor =
|
|
isOutgoing ? colorScheme.onPrimaryContainer : colorScheme.onSurface;
|
|
final metaColor = textColor.withValues(alpha: 0.7);
|
|
final channelColor = widget.channel.isPublicChannel ? Colors.orange : Colors.blue;
|
|
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
IconButton(
|
|
icon: Icon(Icons.location_on_outlined, color: channelColor),
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _showGifPicker(BuildContext context) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (context) => GifPicker(
|
|
onGifSelected: (gifId) {
|
|
_textController.text = 'g:$gifId';
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAvatar(String senderName) {
|
|
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 _buildMessageComposer() {
|
|
final connector = context.watch<MeshCoreConnector>();
|
|
final maxBytes = maxChannelMessageBytes(connector.selfName);
|
|
return Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surface,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.1),
|
|
blurRadius: 4,
|
|
offset: const Offset(0, -2),
|
|
),
|
|
],
|
|
),
|
|
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:
|
|
Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
fallbackTextColor:
|
|
Theme.of(context).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: InputDecoration(
|
|
hintText: 'Type a message...',
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(24),
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 8,
|
|
),
|
|
),
|
|
maxLines: null,
|
|
textInputAction: TextInputAction.send,
|
|
onSubmitted: (_) => _sendMessage(),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton(
|
|
icon: const Icon(Icons.send),
|
|
onPressed: _sendMessage,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _sendMessage() {
|
|
final text = _textController.text.trim();
|
|
if (text.isEmpty) return;
|
|
|
|
final connector = context.read<MeshCoreConnector>();
|
|
final maxBytes = maxChannelMessageBytes(connector.selfName);
|
|
if (utf8.encode(text).length > maxBytes) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Message too long (max $maxBytes bytes).')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
connector.sendChannelMessage(widget.channel, text);
|
|
_textController.clear();
|
|
}
|
|
|
|
String _formatTime(DateTime time) {
|
|
final now = DateTime.now();
|
|
final diff = now.difference(time);
|
|
|
|
if (diff.inDays > 0) {
|
|
return '${time.day}/${time.month} ${time.hour}:${time.minute.toString().padLeft(2, '0')}';
|
|
} else {
|
|
return '${time.hour}:${time.minute.toString().padLeft(2, '0')}';
|
|
}
|
|
}
|
|
|
|
void _showMessagePathInfo(ChannelMessage message) {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => ChannelMessagePathScreen(message: message),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showMessageActions(ChannelMessage message) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
builder: (sheetContext) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
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);
|
|
},
|
|
),
|
|
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(ChannelMessage message) async {
|
|
await context.read<MeshCoreConnector>().deleteChannelMessage(message);
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Message deleted')),
|
|
);
|
|
}
|
|
|
|
String _formatPathPrefixes(Uint8List pathBytes) {
|
|
return pathBytes
|
|
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
|
.join(',');
|
|
}
|
|
}
|
|
|
|
class _PoiInfo {
|
|
final double lat;
|
|
final double lon;
|
|
final String label;
|
|
|
|
const _PoiInfo({
|
|
required this.lat,
|
|
required this.lon,
|
|
required this.label,
|
|
});
|
|
}
|