Review MeshCore payload status

This commit is contained in:
Janez T
2026-03-10 11:05:54 +01:00
parent 8085b89c07
commit 818b9dbfb2
5 changed files with 206 additions and 46 deletions

View File

@@ -213,6 +213,22 @@ class MessagesProvider with ChangeNotifier {
.where((message) => _isMessageForDestination(message, contact)) .where((message) => _isMessageForDestination(message, contact))
.length; .length;
DateTime? getLastActivityForDestination(Contact contact) {
DateTime? latest;
for (final message in _messages) {
if (!_isMessageForDestination(message, contact)) {
continue;
}
if (latest == null || message.sentAt.isAfter(latest)) {
latest = message.sentAt;
}
}
return latest;
}
int getUnreadCountForDestination(Contact contact) => _messages int getUnreadCountForDestination(Contact contact) => _messages
.where( .where(
(message) => (message) =>
@@ -904,7 +920,8 @@ class MessagesProvider with ChangeNotifier {
final matchesDestination = switch (destinationType) { final matchesDestination = switch (destinationType) {
'channel' => _isChannelMessageForContact(message, contact), 'channel' => _isChannelMessageForContact(message, contact),
'contact' || 'room' => contact != null && _isMessageForDestination(message, contact), 'contact' ||
'room' => contact != null && _isMessageForDestination(message, contact),
_ => false, _ => false,
}; };

View File

@@ -665,12 +665,32 @@ class _ChannelActivityCard extends StatelessWidget {
required this.onNavigateToMessages, required this.onNavigateToMessages,
}); });
String _formatRelativeTime(BuildContext context, DateTime when) {
final l10n = AppLocalizations.of(context)!;
final diff = DateTime.now().difference(when);
if (diff.inMinutes < 1) return l10n.justNow;
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
return l10n.daysAgo(diff.inDays);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
);
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0; final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
final channelMessages = messagesProvider.getMessagesForChannel(channelIdx) final channelMessages = messagesProvider.getMessagesForChannel(channelIdx)
..sort((a, b) => b.sentAt.compareTo(a.sentAt)); ..sort((a, b) => b.sentAt.compareTo(a.sentAt));
final lastActivityAt = messagesProvider.getLastActivityForDestination(
channel,
);
final activityLabel = lastActivityAt == null
? null
: _formatRelativeTime(context, lastActivityAt);
final participantNames = <String>[]; final participantNames = <String>[];
for (final message in channelMessages) { for (final message in channelMessages) {
final senderName = message.senderName?.trim(); final senderName = message.senderName?.trim();
@@ -688,12 +708,19 @@ class _ChannelActivityCard extends StatelessWidget {
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [ colors: [
colorScheme.surfaceContainerLow, colorScheme.surfaceContainerLow,
colorScheme.tertiaryContainer.withValues(alpha: 0.45), colorScheme.surfaceContainerHighest.withValues(alpha: 0.9),
], ],
), ),
border: Border.all( border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.35), color: colorScheme.outlineVariant.withValues(alpha: 0.35),
), ),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.045),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
), ),
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
@@ -715,22 +742,34 @@ class _ChannelActivityCard extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
ContactAvatar(contact: channel, radius: 20), ContactAvatar(contact: channel, radius: 24),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
channel.displayName, crossAxisAlignment: CrossAxisAlignment.start,
maxLines: 1, children: [
overflow: TextOverflow.ellipsis, Expanded(
style: Theme.of(context).textTheme.titleMedium child: Text(
?.copyWith( channel.displayName,
fontWeight: FontWeight.w800, maxLines: 1,
fontSize: 15, overflow: TextOverflow.ellipsis,
height: 1.05, style: titleStyle,
), ),
),
const SizedBox(width: 8),
if (activityLabel != null)
Text(
activityLabel,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (participantNames.isNotEmpty) if (participantNames.isNotEmpty)

View File

@@ -772,7 +772,7 @@ class _HomeScreenState extends State<HomeScreen>
Icons.message, Icons.message,
unreadCount, unreadCount,
), ),
text: AppLocalizations.of(context)!.messages, text: 'Chat',
); );
case _HomeTab.contacts: case _HomeTab.contacts:
return Tab( return Tab(

View File

@@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
@@ -5,6 +7,7 @@ import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
import '../services/mesh_map_nodes_service.dart'; import '../services/mesh_map_nodes_service.dart';
@@ -28,6 +31,7 @@ class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
String? _error; String? _error;
double _currentZoom = _fallbackZoom; double _currentZoom = _fallbackZoom;
LatLng? _myLocation; LatLng? _myLocation;
final Set<String> _addingRepeaters = <String>{};
@override @override
void initState() { void initState() {
@@ -164,6 +168,13 @@ class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
} }
void _showRepeaterDetails(_MapRepeater repeater) { void _showRepeaterDetails(_MapRepeater repeater) {
final isAdding = _addingRepeaters.contains(repeater.publicKey);
final canAdd = !repeater.isFromContacts;
final isConnected = context
.read<ConnectionProvider>()
.deviceInfo
.isConnected;
showModalBottomSheet<void>( showModalBottomSheet<void>(
context: context, context: context,
showDragHandle: true, showDragHandle: true,
@@ -192,6 +203,40 @@ class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
'${repeater.latitude.toStringAsFixed(5)}, ${repeater.longitude.toStringAsFixed(5)}', '${repeater.latitude.toStringAsFixed(5)}, ${repeater.longitude.toStringAsFixed(5)}',
), ),
_InfoLine(label: 'Source', value: repeater.sourceLabel), _InfoLine(label: 'Source', value: repeater.sourceLabel),
const SizedBox(height: 12),
if (canAdd)
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: !isConnected || isAdding
? null
: () async {
Navigator.of(context).pop();
await _addRepeaterToContacts(repeater);
},
icon: isAdding
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.person_add_alt_1),
label: Text(
!isConnected
? 'Connect device to add'
: 'Add to contacts',
),
),
)
else
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: null,
icon: const Icon(Icons.check_circle),
label: const Text('Already in contacts'),
),
),
], ],
), ),
), ),
@@ -199,6 +244,59 @@ class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
); );
} }
Future<void> _addRepeaterToContacts(_MapRepeater repeater) async {
if (_addingRepeaters.contains(repeater.publicKey)) {
return;
}
final connectionProvider = context.read<ConnectionProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Connect to a device before adding contacts'),
),
);
return;
}
setState(() {
_addingRepeaters.add(repeater.publicKey);
});
try {
await connectionProvider.getContact(_hexToBytes(repeater.publicKey));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${repeater.name} added to contacts')),
);
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to add ${repeater.name}: $error')),
);
} finally {
if (mounted) {
setState(() {
_addingRepeaters.remove(repeater.publicKey);
});
}
}
}
Uint8List _hexToBytes(String hex) {
final normalized = hex.replaceAll(':', '').trim().toLowerCase();
return Uint8List.fromList(
List<int>.generate(
normalized.length ~/ 2,
(index) => int.parse(
normalized.substring(index * 2, index * 2 + 2),
radix: 16,
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final contactsProvider = context.watch<ContactsProvider>(); final contactsProvider = context.watch<ContactsProvider>();

View File

@@ -36,10 +36,9 @@ class ContactTile extends StatelessWidget {
this.onNavigateToMessages, this.onNavigateToMessages,
}); });
/// Get localized time since last seen String _getLocalizedRelativeTime(BuildContext context, DateTime when) {
String _getLocalizedTimeSinceLastSeen(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final diff = DateTime.now().difference(contact.lastSeenTime); final diff = DateTime.now().difference(when);
if (diff.inMinutes < 1) return l10n.justNow; if (diff.inMinutes < 1) return l10n.justNow;
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes); if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
@@ -50,6 +49,8 @@ class ContactTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isChannel = contact.type == ContactType.channel; final isChannel = contact.type == ContactType.channel;
final isRoom = contact.type == ContactType.room;
final isRoomOrChannel = isRoom || isChannel;
final location = contact.displayLocation; final location = contact.displayLocation;
// Calculate distance if both positions are available // Calculate distance if both positions are available
String? distanceText; String? distanceText;
@@ -68,6 +69,7 @@ class ContactTile extends StatelessWidget {
// Get room login state if this is a room // Get room login state if this is a room
final connectionProvider = context.watch<ConnectionProvider>(); final connectionProvider = context.watch<ConnectionProvider>();
final messagesProvider = context.watch<MessagesProvider>();
final isPingInProgress = connectionProvider.isPingInProgress( final isPingInProgress = connectionProvider.isPingInProgress(
contact.publicKey, contact.publicKey,
); );
@@ -109,40 +111,44 @@ class ContactTile extends StatelessWidget {
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
letterSpacing: -0.3, letterSpacing: -0.3,
); );
final timeAgoText = _getLocalizedTimeSinceLastSeen(context); final lastActivityAt = isRoomOrChannel
? messagesProvider.getLastActivityForDestination(contact)
: null;
final timeAgoText = _getLocalizedRelativeTime(
context,
lastActivityAt ?? contact.lastSeenTime,
);
final timeAgoStyle = Theme.of(context).textTheme.labelSmall?.copyWith( final timeAgoStyle = Theme.of(context).textTheme.labelSmall?.copyWith(
color: contact.isRecentlySeen color: contact.isRecentlySeen
? colorScheme.primary ? colorScheme.primary
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
); );
final Widget? subtitleWidget = isChannel final Widget subtitleWidget = Column(
? null crossAxisAlignment: CrossAxisAlignment.start,
: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, if (location != null) ...[
children: [ const SizedBox(height: 2),
if (location != null) ...[ _buildLocationLine(
const SizedBox(height: 2), context,
_buildLocationLine( latitude: location.latitude,
context, longitude: location.longitude,
latitude: location.latitude, distanceText: distanceText,
longitude: location.longitude, ),
distanceText: distanceText, const SizedBox(height: 6),
), Row(children: [_buildRoutePill(context, contact)]),
const SizedBox(height: 6), ] else
Row(children: [_buildRoutePill(context, contact)]), Padding(
] else padding: const EdgeInsets.only(top: 4),
Padding( child: Text(
padding: const EdgeInsets.only(top: 4), AppLocalizations.of(context)!.noGpsData,
child: Text( style: Theme.of(
AppLocalizations.of(context)!.noGpsData, context,
style: Theme.of( ).textTheme.labelSmall?.copyWith(color: Colors.grey),
context, ),
).textTheme.labelSmall?.copyWith(color: Colors.grey), ),
), ],
), );
],
);
return Container( return Container(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
@@ -239,7 +245,7 @@ class ContactTile extends StatelessWidget {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
if (!isChannel) if (!isRoomOrChannel || lastActivityAt != null)
Text(timeAgoText, style: timeAgoStyle), Text(timeAgoText, style: timeAgoStyle),
if (isPingInProgress) ...[ if (isPingInProgress) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
@@ -254,7 +260,7 @@ class ContactTile extends StatelessWidget {
], ],
], ],
), ),
?subtitleWidget, subtitleWidget,
], ],
), ),
), ),