diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 3922bfa..45e074e 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -213,6 +213,22 @@ class MessagesProvider with ChangeNotifier { .where((message) => _isMessageForDestination(message, contact)) .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 .where( (message) => @@ -904,7 +920,8 @@ class MessagesProvider with ChangeNotifier { final matchesDestination = switch (destinationType) { 'channel' => _isChannelMessageForContact(message, contact), - 'contact' || 'room' => contact != null && _isMessageForDestination(message, contact), + 'contact' || + 'room' => contact != null && _isMessageForDestination(message, contact), _ => false, }; diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index 343f53d..9d8ca8b 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -665,12 +665,32 @@ class _ChannelActivityCard extends StatelessWidget { 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 Widget build(BuildContext context) { 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 channelMessages = messagesProvider.getMessagesForChannel(channelIdx) ..sort((a, b) => b.sentAt.compareTo(a.sentAt)); + final lastActivityAt = messagesProvider.getLastActivityForDestination( + channel, + ); + final activityLabel = lastActivityAt == null + ? null + : _formatRelativeTime(context, lastActivityAt); final participantNames = []; for (final message in channelMessages) { final senderName = message.senderName?.trim(); @@ -688,12 +708,19 @@ class _ChannelActivityCard extends StatelessWidget { end: Alignment.bottomRight, colors: [ colorScheme.surfaceContainerLow, - colorScheme.tertiaryContainer.withValues(alpha: 0.45), + colorScheme.surfaceContainerHighest.withValues(alpha: 0.9), ], ), border: Border.all( 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( color: Colors.transparent, @@ -715,22 +742,34 @@ class _ChannelActivityCard extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - ContactAvatar(contact: channel, radius: 20), + ContactAvatar(contact: channel, radius: 24), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - channel.displayName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith( - fontWeight: FontWeight.w800, - fontSize: 15, - height: 1.05, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + channel.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + 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), if (participantNames.isNotEmpty) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index dfc9b8c..b92a982 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -772,7 +772,7 @@ class _HomeScreenState extends State Icons.message, unreadCount, ), - text: AppLocalizations.of(context)!.messages, + text: 'Chat', ); case _HomeTab.contacts: return Tab( diff --git a/lib/screens/repeaters_map_screen.dart b/lib/screens/repeaters_map_screen.dart index e6b761a..3c49f8e 100644 --- a/lib/screens/repeaters_map_screen.dart +++ b/lib/screens/repeaters_map_screen.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:geolocator/geolocator.dart'; @@ -5,6 +7,7 @@ import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; import '../models/contact.dart'; +import '../providers/connection_provider.dart'; import '../providers/contacts_provider.dart'; import '../services/mesh_map_nodes_service.dart'; @@ -28,6 +31,7 @@ class _RepeatersMapScreenState extends State { String? _error; double _currentZoom = _fallbackZoom; LatLng? _myLocation; + final Set _addingRepeaters = {}; @override void initState() { @@ -164,6 +168,13 @@ class _RepeatersMapScreenState extends State { } void _showRepeaterDetails(_MapRepeater repeater) { + final isAdding = _addingRepeaters.contains(repeater.publicKey); + final canAdd = !repeater.isFromContacts; + final isConnected = context + .read() + .deviceInfo + .isConnected; + showModalBottomSheet( context: context, showDragHandle: true, @@ -192,6 +203,40 @@ class _RepeatersMapScreenState extends State { '${repeater.latitude.toStringAsFixed(5)}, ${repeater.longitude.toStringAsFixed(5)}', ), _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 { ); } + Future _addRepeaterToContacts(_MapRepeater repeater) async { + if (_addingRepeaters.contains(repeater.publicKey)) { + return; + } + + final connectionProvider = context.read(); + 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.generate( + normalized.length ~/ 2, + (index) => int.parse( + normalized.substring(index * 2, index * 2 + 2), + radix: 16, + ), + ), + ); + } + @override Widget build(BuildContext context) { final contactsProvider = context.watch(); diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index e937456..3f0dd2d 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -36,10 +36,9 @@ class ContactTile extends StatelessWidget { this.onNavigateToMessages, }); - /// Get localized time since last seen - String _getLocalizedTimeSinceLastSeen(BuildContext context) { + String _getLocalizedRelativeTime(BuildContext context, DateTime when) { 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 < 60) return l10n.minutesAgo(diff.inMinutes); @@ -50,6 +49,8 @@ class ContactTile extends StatelessWidget { @override Widget build(BuildContext context) { final isChannel = contact.type == ContactType.channel; + final isRoom = contact.type == ContactType.room; + final isRoomOrChannel = isRoom || isChannel; final location = contact.displayLocation; // Calculate distance if both positions are available String? distanceText; @@ -68,6 +69,7 @@ class ContactTile extends StatelessWidget { // Get room login state if this is a room final connectionProvider = context.watch(); + final messagesProvider = context.watch(); final isPingInProgress = connectionProvider.isPingInProgress( contact.publicKey, ); @@ -109,40 +111,44 @@ class ContactTile extends StatelessWidget { fontWeight: FontWeight.w800, 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( color: contact.isRecentlySeen ? colorScheme.primary : colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, ); - final Widget? subtitleWidget = isChannel - ? null - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (location != null) ...[ - const SizedBox(height: 2), - _buildLocationLine( - context, - latitude: location.latitude, - longitude: location.longitude, - distanceText: distanceText, - ), - const SizedBox(height: 6), - Row(children: [_buildRoutePill(context, contact)]), - ] else - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - AppLocalizations.of(context)!.noGpsData, - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: Colors.grey), - ), - ), - ], - ); + final Widget subtitleWidget = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (location != null) ...[ + const SizedBox(height: 2), + _buildLocationLine( + context, + latitude: location.latitude, + longitude: location.longitude, + distanceText: distanceText, + ), + const SizedBox(height: 6), + Row(children: [_buildRoutePill(context, contact)]), + ] else + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + AppLocalizations.of(context)!.noGpsData, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: Colors.grey), + ), + ), + ], + ); return Container( margin: const EdgeInsets.only(bottom: 8), @@ -239,7 +245,7 @@ class ContactTile extends StatelessWidget { ), ), const SizedBox(width: 8), - if (!isChannel) + if (!isRoomOrChannel || lastActivityAt != null) Text(timeAgoText, style: timeAgoStyle), if (isPingInProgress) ...[ const SizedBox(width: 6), @@ -254,7 +260,7 @@ class ContactTile extends StatelessWidget { ], ], ), - ?subtitleWidget, + subtitleWidget, ], ), ),