updated ui added new features

This commit is contained in:
zach
2025-12-27 15:32:32 -07:00
parent 02ca7801ea
commit a2cfae3a22
589 changed files with 181780 additions and 569 deletions
+44
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../services/app_settings_service.dart';
import '../services/notification_service.dart';
import 'map_cache_screen.dart';
class AppSettingsScreen extends StatelessWidget {
const AppSettingsScreen({super.key});
@@ -133,6 +134,31 @@ class AppSettingsScreen extends StatelessWidget {
: null,
),
const Divider(height: 1),
SwitchListTile(
secondary: Icon(
Icons.forum_outlined,
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
title: Text(
'Channel Message Notifications',
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
),
subtitle: Text(
'Show notification when receiving channel messages',
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
),
value: settingsService.settings.notifyOnNewChannelMessage,
onChanged: settingsService.settings.notificationsEnabled
? (value) {
settingsService.setNotifyOnNewChannelMessage(value);
}
: null,
),
const Divider(height: 1),
SwitchListTile(
secondary: Icon(
Icons.cell_tower,
@@ -267,6 +293,24 @@ class AppSettingsScreen extends StatelessWidget {
trailing: const Icon(Icons.chevron_right),
onTap: () => _showTimeFilterDialog(context, settingsService),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.download_outlined),
title: const Text('Offline Map Cache'),
subtitle: Text(
settingsService.settings.mapCacheBounds == null
? 'No area selected'
: 'Area selected (zoom ${settingsService.settings.mapCacheMinZoom}'
'-${settingsService.settings.mapCacheMaxZoom})',
),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const MapCacheScreen()),
);
},
),
],
),
);
+5 -2
View File
@@ -168,6 +168,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final isOutgoing = message.isOutgoing;
final gifId = _parseGifId(message.text);
final poi = _parsePoiMessage(message.text);
final displayPath = message.pathBytes.isNotEmpty
? message.pathBytes
: (message.pathVariants.isNotEmpty ? message.pathVariants.first : Uint8List(0));
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
@@ -223,10 +226,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
message.text,
style: const TextStyle(fontSize: 14),
),
if (message.pathBytes.isNotEmpty) ...[
if (displayPath.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'via ${_formatPathPrefixes(message.pathBytes)}',
'via ${_formatPathPrefixes(displayPath)}',
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
],
+295 -17
View File
@@ -7,6 +7,7 @@ import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../services/map_tile_cache_service.dart';
import '../connector/meshcore_protocol.dart';
import '../models/channel_message.dart';
import '../models/contact.dart';
@@ -23,8 +24,14 @@ class ChannelMessagePathScreen extends StatelessWidget {
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final hops = _buildPathHops(message.pathBytes, connector.contacts);
final hasHopDetails = message.pathBytes.isNotEmpty;
final primaryPath = _selectPrimaryPath(message.pathBytes, message.pathVariants);
final hops = _buildPathHops(primaryPath, connector.contacts);
final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops(
primaryPath.length,
message.pathLength,
);
final extraPaths = _otherPaths(primaryPath, message.pathVariants);
return Scaffold(
appBar: AppBar(
@@ -35,13 +42,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
tooltip: 'View map',
onPressed: hasHopDetails
? () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
ChannelMessagePathMapScreen(message: message),
),
);
_openPathMap(context);
}
: null,
),
@@ -50,8 +51,17 @@ class ChannelMessagePathScreen extends StatelessWidget {
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildSummaryCard(context),
_buildSummaryCard(context, observedLabel: observedLabel),
const SizedBox(height: 16),
if (extraPaths.isNotEmpty) ...[
Text(
'Other Observed Paths',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
_buildPathVariants(context, extraPaths),
const SizedBox(height: 16),
],
Text(
'Repeater Hops',
style: Theme.of(context).textTheme.titleSmall,
@@ -71,7 +81,10 @@ class ChannelMessagePathScreen extends StatelessWidget {
);
}
Widget _buildSummaryCard(BuildContext context) {
Widget _buildSummaryCard(
BuildContext context, {
String? observedLabel,
}) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
@@ -88,12 +101,37 @@ class ChannelMessagePathScreen extends StatelessWidget {
if (message.repeatCount > 0)
_buildDetailRow('Repeats', message.repeatCount.toString()),
_buildDetailRow('Path', _formatPathLabel(message.pathLength)),
if (observedLabel != null) _buildDetailRow('Observed', observedLabel),
],
),
),
);
}
Widget _buildPathVariants(
BuildContext context,
List<Uint8List> variants,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < variants.length; i++)
Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
dense: true,
title: Text(
'Observed path ${i + 1}${_formatHopCount(variants[i].length)}',
),
subtitle: Text(_formatPathPrefixes(variants[i])),
trailing: const Icon(Icons.map_outlined, size: 20),
onTap: () => _openPathMap(context, initialPath: variants[i]),
),
),
],
);
}
List<Widget> _buildHopTiles(List<_PathHop> hops) {
return [
for (final hop in hops)
@@ -138,6 +176,22 @@ class ChannelMessagePathScreen extends StatelessWidget {
return '$pathLength hops';
}
String? _formatObservedHops(int observedCount, int? pathLength) {
if (observedCount <= 0 && (pathLength == null || pathLength <= 0)) {
return null;
}
if (pathLength == null || pathLength < 0) {
return observedCount > 0 ? '$observedCount hops' : null;
}
if (observedCount == 0) {
return '0 of $pathLength hops';
}
if (observedCount == pathLength) {
return '$observedCount hops';
}
return '$observedCount of $pathLength hops';
}
Widget _buildDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
@@ -153,21 +207,71 @@ class ChannelMessagePathScreen extends StatelessWidget {
),
);
}
void _openPathMap(BuildContext context, {Uint8List? initialPath}) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChannelMessagePathMapScreen(
message: message,
initialPath: initialPath,
),
),
);
}
}
class ChannelMessagePathMapScreen extends StatelessWidget {
class ChannelMessagePathMapScreen extends StatefulWidget {
final ChannelMessage message;
final Uint8List? initialPath;
const ChannelMessagePathMapScreen({
super.key,
required this.message,
this.initialPath,
});
@override
State<ChannelMessagePathMapScreen> createState() =>
_ChannelMessagePathMapScreenState();
}
class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScreen> {
Uint8List? _selectedPath;
@override
void initState() {
super.initState();
_selectedPath = widget.initialPath;
}
@override
void didUpdateWidget(ChannelMessagePathMapScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.message != widget.message ||
!_pathsEqual(oldWidget.initialPath ?? Uint8List(0),
widget.initialPath ?? Uint8List(0))) {
_selectedPath = widget.initialPath;
}
}
@override
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final hops = _buildPathHops(message.pathBytes, connector.contacts);
final tileCache = context.read<MapTileCacheService>();
final primaryPath =
_selectPrimaryPath(widget.message.pathBytes, widget.message.pathVariants);
final observedPaths =
_buildObservedPaths(primaryPath, widget.message.pathVariants);
final selectedPath = _resolveSelectedPath(
_selectedPath,
observedPaths,
primaryPath,
);
final selectedIndex = _indexForPath(selectedPath, observedPaths);
final hops = _buildPathHops(selectedPath, connector.contacts);
final points = hops
.where((hop) => hop.hasLocation)
.map((hop) => hop.position!)
@@ -186,6 +290,7 @@ class ChannelMessagePathMapScreen extends StatelessWidget {
points.isNotEmpty ? points.first : const LatLng(0, 0);
final initialZoom = points.isNotEmpty ? 13.0 : 2.0;
final bounds = points.length > 1 ? LatLngBounds.fromPoints(points) : null;
final mapKey = ValueKey(_formatPathPrefixes(selectedPath));
return Scaffold(
appBar: AppBar(
@@ -194,6 +299,7 @@ class ChannelMessagePathMapScreen extends StatelessWidget {
body: Stack(
children: [
FlutterMap(
key: mapKey,
options: MapOptions(
initialCenter: initialCenter,
initialZoom: initialZoom,
@@ -209,9 +315,10 @@ class ChannelMessagePathMapScreen extends StatelessWidget {
),
children: [
TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.meshcore.open',
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName:
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
if (polylines.isNotEmpty) PolylineLayer(polylines: polylines),
@@ -220,6 +327,17 @@ class ChannelMessagePathMapScreen extends StatelessWidget {
),
],
),
if (observedPaths.length > 1)
_buildPathSelector(
context,
observedPaths,
selectedIndex,
(index) {
setState(() {
_selectedPath = observedPaths[index].pathBytes;
});
},
),
if (points.isEmpty)
Center(
child: Card(
@@ -238,6 +356,65 @@ class ChannelMessagePathMapScreen extends StatelessWidget {
);
}
Widget _buildPathSelector(
BuildContext context,
List<_ObservedPath> paths,
int selectedIndex,
ValueChanged<int> onSelected,
) {
final selectedPath = paths[selectedIndex];
final label = selectedPath.isPrimary
? 'Path ${selectedIndex + 1} (Primary)'
: 'Path ${selectedIndex + 1}';
return Positioned(
left: 16,
right: 16,
top: 16,
child: SafeArea(
child: Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Observed Path',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
DropdownButtonHideUnderline(
child: DropdownButton<int>(
isExpanded: true,
value: selectedIndex,
items: [
for (int i = 0; i < paths.length; i++)
DropdownMenuItem(
value: i,
child: Text(
'${paths[i].isPrimary ? 'Path ${i + 1} (Primary)' : 'Path ${i + 1}'}'
'${_formatHopCount(paths[i].pathBytes.length)}',
),
),
],
onChanged: (value) {
if (value == null) return;
onSelected(value);
},
),
),
const SizedBox(height: 4),
Text(
'$label${_formatPathPrefixes(selectedPath.pathBytes)}',
style: TextStyle(color: Colors.grey[700], fontSize: 12),
),
],
),
),
),
),
);
}
List<Marker> _buildHopMarkers(List<_PathHop> hops) {
return [
for (final hop in hops)
@@ -356,6 +533,16 @@ class _PathHop {
}
}
class _ObservedPath {
final Uint8List pathBytes;
final bool isPrimary;
const _ObservedPath({
required this.pathBytes,
required this.isPrimary,
});
}
List<_PathHop> _buildPathHops(Uint8List pathBytes, List<Contact> contacts) {
final hops = <_PathHop>[];
for (var i = 0; i < pathBytes.length; i++) {
@@ -375,7 +562,10 @@ List<_PathHop> _buildPathHops(Uint8List pathBytes, List<Contact> contacts) {
Contact? _matchContactForPrefix(List<Contact> contacts, int prefix) {
final matches = contacts
.where((contact) => contact.publicKey.isNotEmpty && contact.publicKey[0] == prefix)
.where((contact) =>
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
contact.publicKey.isNotEmpty &&
contact.publicKey[0] == prefix)
.toList();
if (matches.isEmpty) return null;
@@ -410,6 +600,16 @@ String _formatPrefix(int prefix) {
return prefix.toRadixString(16).padLeft(2, '0').toUpperCase();
}
String _formatPathPrefixes(Uint8List pathBytes) {
return pathBytes
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(',');
}
String _formatHopCount(int count) {
return '$count ${count == 1 ? 'hop' : 'hops'}';
}
String _resolveName(Contact? contact) {
if (contact == null) return 'Unknown Repeater';
final name = contact.name.trim();
@@ -418,3 +618,81 @@ String _resolveName(Contact? contact) {
}
return name;
}
Uint8List _selectPrimaryPath(Uint8List pathBytes, List<Uint8List> variants) {
Uint8List primary = pathBytes;
for (final variant in variants) {
if (variant.length > primary.length) {
primary = variant;
}
}
return primary;
}
List<Uint8List> _otherPaths(Uint8List primary, List<Uint8List> variants) {
final others = <Uint8List>[];
for (final variant in variants) {
if (variant.isEmpty) continue;
if (!_pathsEqual(primary, variant)) {
others.add(variant);
}
}
return others;
}
List<_ObservedPath> _buildObservedPaths(
Uint8List primary,
List<Uint8List> variants,
) {
final observed = <_ObservedPath>[];
void addPath(Uint8List pathBytes, bool isPrimary) {
if (pathBytes.isEmpty) return;
for (final existing in observed) {
if (_pathsEqual(existing.pathBytes, pathBytes)) return;
}
observed.add(_ObservedPath(pathBytes: pathBytes, isPrimary: isPrimary));
}
addPath(primary, true);
for (final variant in variants) {
addPath(variant, false);
}
return observed;
}
Uint8List _resolveSelectedPath(
Uint8List? selected,
List<_ObservedPath> observedPaths,
Uint8List fallback,
) {
if (selected != null) {
for (final path in observedPaths) {
if (_pathsEqual(path.pathBytes, selected)) {
return path.pathBytes;
}
}
}
if (observedPaths.isNotEmpty) {
return observedPaths.first.pathBytes;
}
return fallback;
}
int _indexForPath(Uint8List selected, List<_ObservedPath> paths) {
for (int i = 0; i < paths.length; i++) {
if (_pathsEqual(paths[i].pathBytes, selected)) {
return i;
}
}
return 0;
}
bool _pathsEqual(Uint8List a, Uint8List b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
+142 -30
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math';
import 'dart:typed_data';
@@ -6,11 +7,21 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../models/channel.dart';
import '../utils/route_transitions.dart';
import '../widgets/quick_switch_bar.dart';
import '../widgets/unread_badge.dart';
import 'channel_chat_screen.dart';
import 'contacts_screen.dart';
import 'map_screen.dart';
import 'settings_screen.dart';
class ChannelsScreen extends StatefulWidget {
const ChannelsScreen({super.key});
final bool hideBackButton;
const ChannelsScreen({
super.key,
this.hideBackButton = false,
});
@override
State<ChannelsScreen> createState() => _ChannelsScreenState();
@@ -31,7 +42,21 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
appBar: AppBar(
title: const Text('Channels'),
centerTitle: true,
automaticallyImplyLeading: !widget.hideBackButton,
actions: [
IconButton(
icon: const Icon(Icons.tune),
tooltip: 'Settings',
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
),
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
onPressed: () => _disconnect(context),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => context.read<MeshCoreConnector>().getChannels(),
@@ -69,20 +94,23 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
}
return ReorderableListView.builder(
padding: const EdgeInsets.all(8),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 88),
buildDefaultDragHandles: false,
itemCount: channels.length,
onReorder: (oldIndex, newIndex) async {
onReorder: (oldIndex, newIndex) {
if (newIndex > oldIndex) newIndex -= 1;
final reordered = List<Channel>.from(channels);
final item = reordered.removeAt(oldIndex);
reordered.insert(newIndex, item);
await connector.setChannelOrder(
reordered.map((c) => c.index).toList(),
unawaited(
connector.setChannelOrder(
reordered.map((c) => c.index).toList(),
),
);
},
itemBuilder: (context, index) {
final channel = channels[index];
return _buildChannelTile(context, connector, channel);
return _buildChannelTile(context, connector, channel, index);
},
);
},
@@ -91,6 +119,13 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
onPressed: () => _showAddChannelDialog(context),
child: const Icon(Icons.add),
),
bottomNavigationBar: SafeArea(
top: false,
child: QuickSwitchBar(
selectedIndex: 1,
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
),
),
);
}
@@ -98,11 +133,17 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
BuildContext context,
MeshCoreConnector connector,
Channel channel,
int index,
) {
final unreadCount = connector.getUnreadCountForChannel(channel);
return Card(
key: ValueKey('channel_${channel.index}'),
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
dense: true,
minVerticalPadding: 0,
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
visualDensity: const VisualDensity(vertical: -2),
leading: CircleAvatar(
backgroundColor: channel.isPublicChannel
? Colors.green.withValues(alpha: 0.2)
@@ -120,36 +161,26 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(
channel.name.startsWith('#')
? 'Hashtag channel'
: channel.isPublicChannel
? 'Public channel'
: 'Private channel',
),
subtitle: Text(
channel.name.startsWith('#')
? 'Hashtag channel'
: channel.isPublicChannel
? 'Public channel'
: 'Private channel',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (unreadCount > 0) ...[
UnreadBadge(count: unreadCount),
const SizedBox(width: 8),
const SizedBox(width: 4),
],
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => _showEditChannelDialog(context, connector, channel),
),
PopupMenuButton<String>(
onSelected: (value) {
if (value == 'delete') {
_confirmDeleteChannel(context, connector, channel);
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'delete',
child: Text('Delete'),
),
],
ReorderableDelayedDragStartListener(
index: index,
child: Icon(
Icons.drag_handle,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
@@ -162,10 +193,91 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
),
);
},
onLongPress: () => _showChannelActions(context, connector, channel),
),
);
}
void _showChannelActions(
BuildContext context,
MeshCoreConnector connector,
Channel channel,
) {
showModalBottomSheet(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit_outlined),
title: const Text('Edit channel'),
onTap: () {
Navigator.pop(context);
_showEditChannelDialog(context, connector, channel);
},
),
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.red),
title: const Text('Delete channel', style: TextStyle(color: Colors.red)),
onTap: () {
Navigator.pop(context);
_confirmDeleteChannel(context, connector, channel);
},
),
],
),
),
);
}
void _handleQuickSwitch(int index, BuildContext context) {
if (index == 1) return;
switch (index) {
case 0:
Navigator.pushReplacement(
context,
buildQuickSwitchRoute(
const ContactsScreen(hideBackButton: true),
),
);
break;
case 2:
Navigator.pushReplacement(
context,
buildQuickSwitchRoute(
const MapScreen(hideBackButton: true),
),
);
break;
}
}
Future<void> _disconnect(BuildContext context) async {
final connector = context.read<MeshCoreConnector>();
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Disconnect'),
content: const Text('Are you sure you want to disconnect from this device?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Disconnect'),
),
],
),
);
if (confirmed == true) {
await connector.disconnect();
}
}
void _showAddChannelDialog(BuildContext context) {
final connector = context.read<MeshCoreConnector>();
final nameController = TextEditingController();
+333 -67
View File
@@ -1,10 +1,13 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:latlong2/latlong.dart';
import 'package:record/record.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
@@ -12,12 +15,14 @@ import '../helpers/utf8_length_limiter.dart';
import '../models/channel_message.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../services/voice_message_service.dart';
import '../services/path_history_service.dart';
import 'channel_message_path_screen.dart';
import 'map_screen.dart';
import '../utils/emoji_utils.dart';
import '../widgets/gif_message.dart';
import '../widgets/gif_picker.dart';
import '../widgets/voice_message.dart';
class ChatScreen extends StatefulWidget {
final Contact contact;
@@ -32,6 +37,16 @@ class _ChatScreenState extends State<ChatScreen> {
final _textController = TextEditingController();
final _scrollController = ScrollController();
bool _forceFlood = false;
final AudioRecorder _voiceRecorder = AudioRecorder();
StreamSubscription<Uint8List>? _voiceStreamSubscription;
BytesBuilder _voiceBuffer = BytesBuilder(copy: false);
Timer? _voiceRecordTimer;
bool _isRecordingVoice = false;
Message? _pendingVoiceMessage;
Uint8List? _pendingVoiceCodec2Bytes;
int? _pendingVoiceTimestampSeconds;
int? _pendingVoiceDurationMs;
String? _pendingVoicePath;
@override
void initState() {
@@ -47,6 +62,11 @@ class _ChatScreenState extends State<ChatScreen> {
context.read<MeshCoreConnector>().setActiveContact(null);
_textController.dispose();
_scrollController.dispose();
_voiceRecordTimer?.cancel();
_voiceStreamSubscription?.cancel();
unawaited(_voiceRecorder.stop());
_voiceRecorder.dispose();
unawaited(_clearPendingVoicePreview(deleteFile: true, notify: false));
super.dispose();
}
@@ -56,35 +76,29 @@ class _ChatScreenState extends State<ChatScreen> {
appBar: AppBar(
title: Consumer2<PathHistoryService, MeshCoreConnector>(
builder: (context, pathService, connector, _) {
final paths = pathService.getRecentPaths(widget.contact.publicKeyHex);
final contact = _resolveContact(connector);
final showRecentPath = paths.isNotEmpty && contact.pathLength >= 0;
final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex);
final unreadLabel = 'Unread: $unreadCount';
final pathLabel = _forceFlood ? 'Flood (forced)' : _currentPathLabel(contact);
final canShowPathDetails = !_forceFlood && contact.path.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(contact.name),
if (showRecentPath)
if (canShowPathDetails)
GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: () => _showFullPathDialog(context, paths.first.pathBytes),
onLongPress: () => _showFullPathDialog(context, contact.path),
child: Text(
'${paths.first.displayText}$unreadLabel',
'$pathLabel$unreadLabel',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal),
),
)
else if (contact.pathLength >= 0)
Text(
'${contact.pathLength} ${contact.pathLength == 1 ? 'hop' : 'hops'}$unreadLabel',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal),
)
else
Text(
'No path$unreadLabel',
'$pathLabel$unreadLabel',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal),
),
@@ -207,10 +221,14 @@ class _ChatScreenState extends State<ChatScreen> {
Widget _buildInputBar(MeshCoreConnector connector) {
final maxBytes = maxContactMessageBytes();
final isVoiceBusy = connector.isVoiceSending;
final voiceSupported = Platform.isAndroid || Platform.isIOS;
final hasPendingVoice = _pendingVoiceMessage != null;
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
color: colorScheme.surface,
border: Border(
top: BorderSide(color: Theme.of(context).dividerColor),
),
@@ -218,59 +236,93 @@ class _ChatScreenState extends State<ChatScreen> {
child: SafeArea(
child: Row(
children: [
if (voiceSupported)
IconButton(
icon: Icon(_isRecordingVoice ? Icons.stop_circle : Icons.mic),
onPressed: (isVoiceBusy || hasPendingVoice) ? null : () => _toggleVoiceRecording(connector),
tooltip: _isRecordingVoice ? 'Stop recording' : 'Record voice',
),
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
onPressed: (_isRecordingVoice || isVoiceBusy || hasPendingVoice)
? null
: () => _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(),
),
],
);
}
child: hasPendingVoice
? _buildVoicePreview(colorScheme)
: 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),
return TextField(
controller: _textController,
enabled: !_isRecordingVoice && !isVoiceBusy,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes),
],
decoration: const InputDecoration(
hintText: 'Type a message...',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
textInputAction: TextInputAction.send,
onSubmitted: (_isRecordingVoice || isVoiceBusy)
? null
: (_) => _sendMessage(connector),
);
},
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(connector),
);
},
),
),
const SizedBox(width: 8),
IconButton.filled(
icon: const Icon(Icons.send),
onPressed: () => _sendMessage(connector),
),
if (isVoiceBusy)
IconButton.filled(
icon: const Icon(Icons.stop_circle),
onPressed: () => _cancelVoiceSend(connector),
tooltip: 'Cancel voice send',
)
else if (hasPendingVoice) ...[
IconButton(
icon: const Icon(Icons.close),
onPressed: () => _clearPendingVoicePreview(deleteFile: true),
tooltip: 'Discard voice message',
),
IconButton.filled(
icon: const Icon(Icons.send),
onPressed: () => _sendPendingVoice(connector),
tooltip: 'Send voice message',
),
]
else
IconButton.filled(
icon: const Icon(Icons.send),
onPressed: (_isRecordingVoice || isVoiceBusy)
? null
: () => _sendMessage(connector),
),
],
),
),
@@ -325,6 +377,209 @@ class _ChatScreenState extends State<ChatScreen> {
});
}
void _cancelVoiceSend(MeshCoreConnector connector) {
connector.cancelVoiceSend();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Voice send canceled')),
);
}
Future<void> _toggleVoiceRecording(MeshCoreConnector connector) async {
if (_isRecordingVoice) {
await _stopVoiceRecording(connector);
} else {
await _startVoiceRecording();
}
}
Future<void> _startVoiceRecording() async {
if (_isRecordingVoice) return;
final hasPermission = await _voiceRecorder.hasPermission();
if (!hasPermission) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Microphone permission denied')),
);
return;
}
_voiceBuffer = BytesBuilder(copy: false);
try {
final stream = await _voiceRecorder.startStream(
const RecordConfig(
encoder: AudioEncoder.pcm16bits,
sampleRate: VoiceMessageService.sampleRate,
numChannels: VoiceMessageService.channels,
),
);
_voiceStreamSubscription = stream.listen((data) {
_voiceBuffer.add(data);
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to start recording: $e')),
);
return;
}
_voiceRecordTimer?.cancel();
_voiceRecordTimer = Timer(
const Duration(seconds: VoiceMessageService.maxRecordSeconds),
() => _stopVoiceRecording(context.read<MeshCoreConnector>()),
);
setState(() {
_isRecordingVoice = true;
});
}
Future<void> _stopVoiceRecording(MeshCoreConnector connector) async {
if (!_isRecordingVoice) return;
_voiceRecordTimer?.cancel();
await _voiceRecorder.stop();
await _voiceStreamSubscription?.cancel();
_voiceStreamSubscription = null;
final pcmBytes = _voiceBuffer.takeBytes();
setState(() {
_isRecordingVoice = false;
});
if (pcmBytes.isEmpty) return;
await _prepareVoicePreview(connector, pcmBytes);
}
Future<void> _prepareVoicePreview(MeshCoreConnector connector, Uint8List pcmBytes) async {
final voiceService = VoiceMessageService.instance;
try {
final codec2Bytes = voiceService.encodePcmToCodec2(pcmBytes);
if (codec2Bytes.isEmpty) return;
final timestampSeconds = connector.reserveVoiceTimestampSeconds();
final durationMs = voiceService.durationMsForCodec2Bytes(codec2Bytes);
final decodedPcm = voiceService.decodeCodec2ToPcm(codec2Bytes);
final fileName = voiceService.buildVoiceFileName(
senderKeyHex: widget.contact.publicKeyHex,
timestampSeconds: timestampSeconds,
outgoing: true,
);
final voicePath = await voiceService.writeWavFile(
pcmBytes: decodedPcm,
fileName: fileName,
);
final previewMessage = Message(
senderKey: widget.contact.publicKey,
text: 'Voice message',
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampSeconds * 1000),
isOutgoing: true,
isCli: false,
status: MessageStatus.pending,
isVoice: true,
voicePath: voicePath,
voiceDurationMs: durationMs,
voiceCodec: VoiceMessageService.codecName,
);
if (!mounted) return;
setState(() {
_pendingVoiceMessage = previewMessage;
_pendingVoiceCodec2Bytes = codec2Bytes;
_pendingVoiceTimestampSeconds = timestampSeconds;
_pendingVoiceDurationMs = durationMs;
_pendingVoicePath = voicePath;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Voice message failed: $e')),
);
}
}
Widget _buildVoicePreview(ColorScheme colorScheme) {
final message = _pendingVoiceMessage;
if (message == null) {
return const SizedBox.shrink();
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: VoiceMessageBubble(
message: message,
backgroundColor: colorScheme.surfaceContainerHighest,
textColor: colorScheme.onSurface,
metaColor: colorScheme.onSurface.withValues(alpha: 0.7),
isOutgoing: true,
),
);
}
Future<void> _sendPendingVoice(MeshCoreConnector connector) async {
final codec2Bytes = _pendingVoiceCodec2Bytes;
final voicePath = _pendingVoicePath;
final durationMs = _pendingVoiceDurationMs;
final timestampSeconds = _pendingVoiceTimestampSeconds;
if (codec2Bytes == null ||
codec2Bytes.isEmpty ||
voicePath == null ||
voicePath.isEmpty ||
durationMs == null ||
timestampSeconds == null) {
return;
}
if (!connector.isConnected) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Not connected to a MeshCore device')),
);
return;
}
if (connector.isVoiceSending) {
return;
}
await connector.sendVoiceMessage(
contact: widget.contact,
codec2Bytes: codec2Bytes,
voicePath: voicePath,
durationMs: durationMs,
timestampSeconds: timestampSeconds,
);
unawaited(_clearPendingVoicePreview(deleteFile: false));
}
Future<void> _clearPendingVoicePreview({required bool deleteFile, bool notify = true}) async {
final path = _pendingVoicePath;
if (notify && mounted) {
setState(() {
_pendingVoiceMessage = null;
_pendingVoiceCodec2Bytes = null;
_pendingVoiceTimestampSeconds = null;
_pendingVoiceDurationMs = null;
_pendingVoicePath = null;
});
} else {
_pendingVoiceMessage = null;
_pendingVoiceCodec2Bytes = null;
_pendingVoiceTimestampSeconds = null;
_pendingVoiceDurationMs = null;
_pendingVoicePath = null;
}
if (deleteFile && path != null && path.isNotEmpty) {
try {
final file = File(path);
if (await file.exists()) {
await file.delete();
}
} catch (_) {
return;
}
}
}
void _showPathHistory(BuildContext context) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
@@ -1024,14 +1279,15 @@ class _ChatScreenState extends State<ChatScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.copy),
title: const Text('Copy'),
onTap: () {
Navigator.pop(sheetContext);
_copyMessageText(message.text);
},
),
if (!message.isVoice)
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'),
@@ -1040,7 +1296,9 @@ class _ChatScreenState extends State<ChatScreen> {
await _deleteMessage(message);
},
),
if (message.isOutgoing && message.status == MessageStatus.failed)
if (message.isOutgoing &&
message.status == MessageStatus.failed &&
!message.isVoice)
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Retry'),
@@ -1154,7 +1412,15 @@ class _MessageBubble extends StatelessWidget {
),
const SizedBox(height: 4),
],
if (poi != null)
if (message.isVoice)
VoiceMessageBubble(
message: message,
backgroundColor: bubbleColor,
textColor: textColor,
metaColor: metaColor,
isOutgoing: isOutgoing,
)
else if (poi != null)
_buildPoiMessage(context, poi, textColor, metaColor)
else if (gifId != null)
GifMessage(
+346 -256
View File
@@ -6,11 +6,17 @@ import '../connector/meshcore_protocol.dart';
import '../models/contact.dart';
import '../models/contact_group.dart';
import '../storage/contact_group_store.dart';
import '../utils/contact_search.dart';
import '../utils/emoji_utils.dart';
import '../utils/route_transitions.dart';
import '../widgets/quick_switch_bar.dart';
import '../widgets/repeater_login_dialog.dart';
import '../widgets/unread_badge.dart';
import '../utils/emoji_utils.dart';
import 'channels_screen.dart';
import 'chat_screen.dart';
import 'map_screen.dart';
import 'repeater_hub_screen.dart';
import 'settings_screen.dart';
enum ContactSortOption {
lastSeen,
@@ -19,8 +25,22 @@ enum ContactSortOption {
type,
}
enum _ContactMenuAction {
sortRecentMessages,
sortName,
sortType,
toggleLastSeenFilter,
toggleUnreadOnly,
newGroup,
}
class ContactsScreen extends StatefulWidget {
const ContactsScreen({super.key});
final bool hideBackButton;
const ContactsScreen({
super.key,
this.hideBackButton = false,
});
@override
State<ContactsScreen> createState() => _ContactsScreenState();
@@ -30,6 +50,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
ContactSortOption _sortOption = ContactSortOption.lastSeen;
bool _forceLastSeenSort = true;
bool _showUnreadOnly = false;
final ContactGroupStore _groupStore = ContactGroupStore();
List<ContactGroup> _groups = [];
@@ -60,275 +81,309 @@ class _ContactsScreenState extends State<ContactsScreen> {
@override
Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>();
if (!connector.isConnected) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
Navigator.popUntil(context, (route) => route.isFirst);
}
});
}
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Contacts'),
centerTitle: true,
actions: [
PopupMenuButton<ContactSortOption>(
icon: const Icon(Icons.sort),
tooltip: 'Sort by',
onSelected: (option) {
setState(() {
_sortOption = option;
});
},
itemBuilder: (context) => [
PopupMenuItem(
value: ContactSortOption.lastSeen,
child: Row(
children: [
Icon(
Icons.access_time,
size: 20,
color: _sortOption == ContactSortOption.lastSeen
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 12),
Text(
'Last Seen',
style: TextStyle(
fontWeight: _sortOption == ContactSortOption.lastSeen
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
titleSpacing: 16,
centerTitle: false,
automaticallyImplyLeading: !widget.hideBackButton,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Contacts'),
Text(
'${connector.contacts.length} contacts',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
PopupMenuItem(
value: ContactSortOption.recentMessages,
child: Row(
children: [
Icon(
Icons.chat_bubble,
size: 20,
color: _sortOption == ContactSortOption.recentMessages
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 12),
Text(
'Recent Messages',
style: TextStyle(
fontWeight: _sortOption == ContactSortOption.recentMessages
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: ContactSortOption.name,
child: Row(
children: [
Icon(
Icons.sort_by_alpha,
size: 20,
color: _sortOption == ContactSortOption.name
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 12),
Text(
'Name',
style: TextStyle(
fontWeight: _sortOption == ContactSortOption.name
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: ContactSortOption.type,
child: Row(
children: [
Icon(
Icons.category,
size: 20,
color: _sortOption == ContactSortOption.type
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 12),
Text(
'Type',
style: TextStyle(
fontWeight: _sortOption == ContactSortOption.type
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
],
),
IconButton(
icon: Icon(
Icons.mark_chat_unread_outlined,
color: _showUnreadOnly ? Theme.of(context).primaryColor : null,
),
tooltip: _showUnreadOnly ? 'Showing unread only' : 'Show unread only',
onPressed: () {
setState(() {
_showUnreadOnly = !_showUnreadOnly;
});
},
],
),
actions: [
IconButton(
icon: connector.isLoadingContacts
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
tooltip: 'Refresh',
onPressed: connector.isLoadingContacts ? null : () => connector.getContacts(),
),
IconButton(
icon: const Icon(Icons.group_add),
tooltip: 'New group',
onPressed: () {
final contacts = context.read<MeshCoreConnector>().contacts;
_showGroupEditor(context, contacts);
},
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
onPressed: () => _disconnect(context, connector),
),
Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
return IconButton(
icon: connector.isLoadingContacts
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: connector.isLoadingContacts
? null
: () => connector.getContacts(),
IconButton(
icon: const Icon(Icons.tune),
tooltip: 'Settings',
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
),
PopupMenuButton<_ContactMenuAction>(
tooltip: 'Contacts options',
onSelected: (action) {
switch (action) {
case _ContactMenuAction.sortRecentMessages:
setState(() {
_sortOption = ContactSortOption.recentMessages;
_forceLastSeenSort = false;
});
break;
case _ContactMenuAction.sortName:
setState(() {
_sortOption = ContactSortOption.name;
_forceLastSeenSort = false;
});
break;
case _ContactMenuAction.sortType:
setState(() {
_sortOption = ContactSortOption.type;
_forceLastSeenSort = false;
});
break;
case _ContactMenuAction.toggleLastSeenFilter:
setState(() {
_forceLastSeenSort = !_forceLastSeenSort;
if (_forceLastSeenSort) {
_sortOption = ContactSortOption.lastSeen;
}
});
break;
case _ContactMenuAction.toggleUnreadOnly:
setState(() {
_showUnreadOnly = !_showUnreadOnly;
});
break;
case _ContactMenuAction.newGroup:
_showGroupEditor(context, connector.contacts);
break;
}
},
itemBuilder: (context) {
final labelStyle = theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
);
return [
PopupMenuItem<_ContactMenuAction>(
enabled: false,
child: Text('Sort by', style: labelStyle),
),
CheckedPopupMenuItem<_ContactMenuAction>(
value: _ContactMenuAction.sortRecentMessages,
checked: _sortOption == ContactSortOption.recentMessages,
child: const Text('Recent messages'),
),
CheckedPopupMenuItem<_ContactMenuAction>(
value: _ContactMenuAction.sortName,
checked: _sortOption == ContactSortOption.name,
child: const Text('Name'),
),
CheckedPopupMenuItem<_ContactMenuAction>(
value: _ContactMenuAction.sortType,
checked: _sortOption == ContactSortOption.type,
child: const Text('Type'),
),
const PopupMenuDivider(),
PopupMenuItem<_ContactMenuAction>(
enabled: false,
child: Text('Filters', style: labelStyle),
),
CheckedPopupMenuItem<_ContactMenuAction>(
value: _ContactMenuAction.toggleLastSeenFilter,
checked: _forceLastSeenSort,
child: const Text('Last seen'),
),
CheckedPopupMenuItem<_ContactMenuAction>(
value: _ContactMenuAction.toggleUnreadOnly,
checked: _showUnreadOnly,
child: const Text('Unread only'),
),
PopupMenuItem<_ContactMenuAction>(
value: _ContactMenuAction.newGroup,
child: const Text('New group'),
),
];
},
),
],
),
body: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
final contacts = connector.contacts;
if (contacts.isEmpty && connector.isLoadingContacts && _groups.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (contacts.isEmpty && _groups.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.people_outline, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No contacts yet',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
'Contacts will appear when devices advertise',
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
],
),
);
}
final filteredAndSorted = _filterAndSortContacts(contacts, connector);
final filteredGroups =
_showUnreadOnly ? const <ContactGroup>[] : _filterAndSortGroups(_groups, contacts);
return Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search contacts...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
onChanged: (value) {
setState(() {
_searchQuery = value.toLowerCase();
});
},
),
),
Expanded(
child: filteredAndSorted.isEmpty && filteredGroups.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
_showUnreadOnly
? 'No unread contacts'
: 'No contacts or groups found',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
),
)
: RefreshIndicator(
onRefresh: () => connector.getContacts(),
child: ListView.builder(
itemCount: filteredGroups.length + filteredAndSorted.length,
itemBuilder: (context, index) {
if (index < filteredGroups.length) {
final group = filteredGroups[index];
return _buildGroupTile(context, group, contacts);
}
final contact = filteredAndSorted[index - filteredGroups.length];
final unreadCount = connector.getUnreadCountForContact(contact);
return _ContactTile(
contact: contact,
unreadCount: unreadCount,
onTap: () => _openChat(context, contact),
onLongPress: () => _showContactOptions(context, connector, contact),
);
},
),
),
),
],
);
},
body: _buildContactsBody(context, connector),
bottomNavigationBar: SafeArea(
top: false,
child: QuickSwitchBar(
selectedIndex: 0,
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
),
),
);
}
Future<void> _disconnect(
BuildContext context,
MeshCoreConnector connector,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Disconnect'),
content: const Text('Are you sure you want to disconnect from this device?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Disconnect'),
),
],
),
);
if (confirmed == true) {
await connector.disconnect();
}
}
Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
final contacts = connector.contacts;
if (contacts.isEmpty && connector.isLoadingContacts && _groups.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (contacts.isEmpty && _groups.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.people_outline, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No contacts yet',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
'Contacts will appear when devices advertise',
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
],
),
);
}
final filteredAndSorted = _filterAndSortContacts(contacts, connector);
final filteredGroups =
_showUnreadOnly ? const <ContactGroup>[] : _filterAndSortGroups(_groups, contacts);
return Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search contacts...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
onChanged: (value) {
setState(() {
_searchQuery = value.toLowerCase();
});
},
),
),
Expanded(
child: filteredAndSorted.isEmpty && filteredGroups.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
_showUnreadOnly
? 'No unread contacts'
: 'No contacts or groups found',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
),
)
: RefreshIndicator(
onRefresh: () => connector.getContacts(),
child: ListView.builder(
itemCount: filteredGroups.length + filteredAndSorted.length,
itemBuilder: (context, index) {
if (index < filteredGroups.length) {
final group = filteredGroups[index];
return _buildGroupTile(context, group, contacts);
}
final contact = filteredAndSorted[index - filteredGroups.length];
final unreadCount = connector.getUnreadCountForContact(contact);
return _ContactTile(
contact: contact,
lastSeen: _resolveLastSeen(contact),
unreadCount: unreadCount,
onTap: () => _openChat(context, contact),
onLongPress: () => _showContactOptions(context, connector, contact),
);
},
),
),
),
],
);
}
List<ContactGroup> _filterAndSortGroups(List<ContactGroup> groups, List<Contact> contacts) {
final query = _searchQuery.trim().toLowerCase();
final contactNames = <String, String>{};
final contactsByKey = <String, Contact>{};
for (final contact in contacts) {
contactNames[contact.publicKeyHex] = contact.name.toLowerCase();
contactsByKey[contact.publicKeyHex] = contact;
}
final filtered = groups.where((group) {
if (query.isEmpty) return true;
if (group.name.toLowerCase().contains(query)) return true;
for (final key in group.memberKeys) {
final name = contactNames[key];
if (name != null && name.contains(query)) return true;
final contact = contactsByKey[key];
if (contact != null && matchesContactQuery(contact, query)) return true;
}
return false;
}).toList();
@@ -340,7 +395,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
List<Contact> _filterAndSortContacts(List<Contact> contacts, MeshCoreConnector connector) {
var filtered = contacts.where((contact) {
if (_searchQuery.isEmpty) return true;
return contact.name.toLowerCase().contains(_searchQuery);
return matchesContactQuery(contact, _searchQuery);
}).toList();
if (_showUnreadOnly) {
@@ -349,9 +404,10 @@ class _ContactsScreenState extends State<ContactsScreen> {
}).toList();
}
switch (_sortOption) {
final sortOption = _forceLastSeenSort ? ContactSortOption.lastSeen : _sortOption;
switch (sortOption) {
case ContactSortOption.lastSeen:
filtered.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
filtered.sort((a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)));
break;
case ContactSortOption.recentMessages:
filtered.sort((a, b) {
@@ -377,6 +433,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
return filtered;
}
DateTime _resolveLastSeen(Contact contact) {
if (contact.type != advTypeChat) return contact.lastSeen;
return contact.lastMessageAt.isAfter(contact.lastSeen)
? contact.lastMessageAt
: contact.lastSeen;
}
Widget _buildGroupTile(BuildContext context, ContactGroup group, List<Contact> contacts) {
final memberContacts = _resolveGroupContacts(group, contacts);
final subtitle = _formatGroupMembers(memberContacts);
@@ -432,6 +495,28 @@ class _ContactsScreenState extends State<ContactsScreen> {
}
}
void _handleQuickSwitch(int index, BuildContext context) {
if (index == 0) return;
switch (index) {
case 1:
Navigator.pushReplacement(
context,
buildQuickSwitchRoute(
const ChannelsScreen(hideBackButton: true),
),
);
break;
case 2:
Navigator.pushReplacement(
context,
buildQuickSwitchRoute(
const MapScreen(hideBackButton: true),
),
);
break;
}
}
void _showRepeaterLogin(BuildContext context, Contact repeater) {
showDialog(
context: context,
@@ -542,7 +627,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
final filteredContacts = filterQuery.isEmpty
? sortedContacts
: sortedContacts
.where((contact) => contact.name.toLowerCase().contains(filterQuery))
.where((contact) => matchesContactQuery(contact, filterQuery))
.toList();
return AlertDialog(
title: Text(isEditing ? 'Edit Group' : 'New Group'),
@@ -728,12 +813,14 @@ class _ContactsScreenState extends State<ContactsScreen> {
class _ContactTile extends StatelessWidget {
final Contact contact;
final DateTime lastSeen;
final int unreadCount;
final VoidCallback onTap;
final VoidCallback onLongPress;
const _ContactTile({
required this.contact,
required this.lastSeen,
required this.unreadCount,
required this.onTap,
required this.onLongPress,
@@ -757,7 +844,7 @@ class _ContactTile extends StatelessWidget {
const SizedBox(height: 4),
],
Text(
_formatLastSeen(contact.lastSeen),
_formatLastSeen(lastSeen),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
if (contact.hasLocation)
@@ -814,10 +901,13 @@ class _ContactTile extends StatelessWidget {
final now = DateTime.now();
final diff = now.difference(lastSeen);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
return '${lastSeen.month}/${lastSeen.day}';
if (diff.isNegative || diff.inMinutes < 5) return 'Last seen now';
if (diff.inMinutes < 60) return 'Last seen ${diff.inMinutes} mins ago';
if (diff.inHours < 24) {
final hours = diff.inHours;
return hours == 1 ? 'Last seen 1 hour ago' : 'Last seen $hours hours ago';
}
final days = diff.inDays;
return days == 1 ? 'Last seen 1 day ago' : 'Last seen $days days ago';
}
}
+191 -172
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../utils/route_transitions.dart';
import '../widgets/quick_switch_bar.dart';
import 'channels_screen.dart';
import 'contacts_screen.dart';
import 'map_screen.dart';
@@ -17,6 +19,7 @@ class DeviceScreen extends StatefulWidget {
class _DeviceScreenState extends State<DeviceScreen> {
bool _showBatteryVoltage = false;
int _quickIndex = 0;
@override
Widget build(BuildContext context) {
@@ -31,14 +34,26 @@ class _DeviceScreenState extends State<DeviceScreen> {
});
}
final theme = Theme.of(context);
return PopScope(
canPop: false,
child: Scaffold(
appBar: AppBar(
title: Text(connector.deviceDisplayName),
centerTitle: true,
automaticallyImplyLeading: false,
titleSpacing: 16,
centerTitle: false,
title: _buildAppBarTitle(connector, theme),
actions: [
IconButton(
icon: const Icon(Icons.tune),
tooltip: 'Settings',
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
),
),
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
@@ -46,20 +61,15 @@ class _DeviceScreenState extends State<DeviceScreen> {
),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
children: [
// Connection status card
_buildStatusCard(connector, context),
const SizedBox(height: 24),
// Navigation grid
Expanded(
child: _buildNavigationGrid(context),
),
_buildConnectionCard(connector, context),
const SizedBox(height: 16),
_buildSectionLabel(theme, 'Quick switch'),
const SizedBox(height: 12),
_buildQuickSwitchBar(context),
],
),
),
@@ -69,54 +79,114 @@ class _DeviceScreenState extends State<DeviceScreen> {
);
}
Widget _buildStatusCard(MeshCoreConnector connector, BuildContext context) {
Widget _buildAppBarTitle(MeshCoreConnector connector, ThemeData theme) {
final colorScheme = theme.colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'MeshCore',
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
color: colorScheme.onSurfaceVariant,
),
),
Text(
connector.deviceDisplayName,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
);
}
Widget _buildSectionLabel(ThemeData theme, String text) {
return Text(
text,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: 0.6,
color: theme.colorScheme.onSurfaceVariant,
),
);
}
Widget _buildConnectionCard(
MeshCoreConnector connector,
BuildContext context,
) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Card(
elevation: 0,
color: colorScheme.surfaceVariant,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.bluetooth_connected, color: Colors.green, size: 32),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
connector.deviceDisplayName,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
connector.deviceIdLabel,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: const Text(
'Connected',
style: TextStyle(
color: Colors.green,
fontWeight: FontWeight.w500,
),
CircleAvatar(
radius: 24,
backgroundColor: colorScheme.primaryContainer,
child: Icon(
Icons.wifi_tethering_rounded,
color: colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 8),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
connector.deviceDisplayName,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
connector.deviceIdLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Chip(
avatar: Icon(
Icons.check_circle,
size: 18,
color: colorScheme.onSecondaryContainer,
),
label: const Text('Connected'),
backgroundColor: colorScheme.secondaryContainer,
labelStyle: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
visualDensity: VisualDensity.compact,
),
_buildBatteryIndicator(connector, context),
],
),
@@ -126,7 +196,22 @@ class _DeviceScreenState extends State<DeviceScreen> {
);
}
Widget _buildBatteryIndicator(MeshCoreConnector connector, BuildContext context) {
Widget _buildQuickSwitchBar(BuildContext context) {
return QuickSwitchBar(
selectedIndex: _quickIndex,
onDestinationSelected: (index) {
_openQuickDestination(index, context);
},
);
}
Widget _buildBatteryIndicator(
MeshCoreConnector connector,
BuildContext context,
) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final percent = connector.batteryPercent;
final millivolts = connector.batteryMillivolts;
final percentLabel = percent != null ? '$percent%' : '--%';
@@ -136,31 +221,24 @@ class _DeviceScreenState extends State<DeviceScreen> {
final displayLabel = _showBatteryVoltage ? voltageLabel : percentLabel;
final icon = _batteryIcon(percent);
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
return ActionChip(
avatar: Icon(
icon,
size: 16,
color: colorScheme.onSecondaryContainer,
),
label: Text(displayLabel),
labelStyle: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
backgroundColor: colorScheme.secondaryContainer,
visualDensity: VisualDensity.compact,
onPressed: () {
setState(() {
_showBatteryVoltage = !_showBatteryVoltage;
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: Colors.grey[700]),
const SizedBox(width: 4),
Text(
displayLabel,
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
@@ -170,89 +248,44 @@ class _DeviceScreenState extends State<DeviceScreen> {
return Icons.battery_full;
}
Widget _buildNavigationGrid(BuildContext context) {
final items = [
_NavItem(
icon: Icons.people_outline,
label: 'Contacts',
color: Colors.blue,
onTap: () => Navigator.push(
void _openQuickDestination(int index, BuildContext context) {
if (_quickIndex != index) {
setState(() {
_quickIndex = index;
});
}
switch (index) {
case 0:
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const ContactsScreen()),
),
),
_NavItem(
icon: Icons.tag,
label: 'Channels',
color: Colors.green,
onTap: () => Navigator.push(
buildQuickSwitchRoute(
const ContactsScreen(hideBackButton: true),
),
);
break;
case 1:
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const ChannelsScreen()),
),
),
_NavItem(
icon: Icons.map_outlined,
label: 'Map',
color: Colors.orange,
onTap: () => Navigator.push(
buildQuickSwitchRoute(
const ChannelsScreen(hideBackButton: true),
),
);
break;
case 2:
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const MapScreen()),
),
),
_NavItem(
icon: Icons.settings_outlined,
label: 'Settings',
color: Colors.grey,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
),
];
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 1.2,
),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return _buildNavCard(item);
},
);
buildQuickSwitchRoute(
const MapScreen(hideBackButton: true),
),
);
break;
}
}
Widget _buildNavCard(_NavItem item) {
return Card(
child: InkWell(
onTap: item.onTap,
borderRadius: BorderRadius.circular(12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
item.icon,
size: 48,
color: item.color,
),
const SizedBox(height: 12),
Text(
item.label,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
Future<void> _disconnect(BuildContext context, MeshCoreConnector connector) async {
Future<void> _disconnect(
BuildContext context,
MeshCoreConnector connector,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
@@ -276,17 +309,3 @@ class _DeviceScreenState extends State<DeviceScreen> {
}
}
}
class _NavItem {
final IconData icon;
final String label;
final Color color;
final VoidCallback onTap;
_NavItem({
required this.icon,
required this.label,
required this.color,
required this.onTap,
});
}
+390
View File
@@ -0,0 +1,390 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../services/app_settings_service.dart';
import '../services/map_tile_cache_service.dart';
class MapCacheScreen extends StatefulWidget {
const MapCacheScreen({super.key});
@override
State<MapCacheScreen> createState() => _MapCacheScreenState();
}
class _MapCacheScreenState extends State<MapCacheScreen> {
final MapController _mapController = MapController();
LatLngBounds? _selectedBounds;
int _minZoom = MapTileCacheService.defaultMinZoom;
int _maxZoom = MapTileCacheService.defaultMaxZoom;
int _estimatedTiles = 0;
bool _isDownloading = false;
int _completedTiles = 0;
int _failedTiles = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_loadSettings();
});
}
@override
void dispose() {
_mapController.dispose();
super.dispose();
}
void _loadSettings() {
final settings = context.read<AppSettingsService>().settings;
final bounds = MapTileCacheService.boundsFromJson(settings.mapCacheBounds);
final minZoom = settings.mapCacheMinZoom.clamp(3, 18);
final maxZoom = settings.mapCacheMaxZoom.clamp(3, 18);
final safeMin = minZoom <= maxZoom ? minZoom : maxZoom;
final safeMax = minZoom <= maxZoom ? maxZoom : minZoom;
setState(() {
_minZoom = safeMin;
_maxZoom = safeMax;
_selectedBounds = bounds;
});
_updateEstimate();
if (bounds != null) {
_mapController.fitCamera(
CameraFit.bounds(
bounds: bounds,
padding: const EdgeInsets.all(48),
),
);
}
}
void _updateEstimate() {
if (_selectedBounds == null) {
setState(() {
_estimatedTiles = 0;
});
return;
}
final cacheService = context.read<MapTileCacheService>();
final count =
cacheService.estimateTileCount(_selectedBounds!, _minZoom, _maxZoom);
setState(() {
_estimatedTiles = count;
});
}
Future<void> _setBoundsFromView() async {
final bounds = _mapController.camera.visibleBounds;
await _saveBounds(bounds);
}
Future<void> _saveBounds(LatLngBounds bounds) async {
setState(() {
_selectedBounds = bounds;
});
final settings = context.read<AppSettingsService>();
await settings.setMapCacheBounds(MapTileCacheService.boundsToJson(bounds));
_updateEstimate();
}
Future<void> _clearBounds() async {
setState(() {
_selectedBounds = null;
_estimatedTiles = 0;
});
final settings = context.read<AppSettingsService>();
await settings.setMapCacheBounds(null);
}
Future<void> _saveZoomRange() async {
final settings = context.read<AppSettingsService>();
await settings.setMapCacheZoomRange(_minZoom, _maxZoom);
_updateEstimate();
}
Future<void> _startDownload() async {
final bounds = _selectedBounds;
if (bounds == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Select an area to cache first')),
);
return;
}
if (_estimatedTiles == 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No tiles to download for this area')),
);
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Download tiles'),
content: Text(
'Download $_estimatedTiles tiles for offline use?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Download'),
),
],
),
);
if (confirmed != true) return;
setState(() {
_isDownloading = true;
_completedTiles = 0;
_failedTiles = 0;
});
final cacheService = context.read<MapTileCacheService>();
final result = await cacheService.downloadRegion(
bounds: bounds,
minZoom: _minZoom,
maxZoom: _maxZoom,
onProgress: (progress) {
if (!mounted) return;
setState(() {
_completedTiles = progress.completed;
_failedTiles = progress.failed;
});
},
);
if (!mounted) return;
setState(() {
_isDownloading = false;
_completedTiles = result.downloaded + result.failed;
_failedTiles = result.failed;
});
final message = result.failed > 0
? 'Cached ${result.downloaded} tiles (${result.failed} failed)'
: 'Cached ${result.downloaded} tiles';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
Future<void> _clearCache() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Clear offline cache'),
content: const Text('Remove all cached map tiles?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Clear'),
),
],
),
);
if (confirmed != true) return;
final cacheService = context.read<MapTileCacheService>();
await cacheService.clearCache();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Offline cache cleared')),
);
}
@override
Widget build(BuildContext context) {
final tileCache = context.read<MapTileCacheService>();
final selectedBounds = _selectedBounds;
final progressValue = _estimatedTiles == 0
? 0.0
: (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble();
return Scaffold(
appBar: AppBar(
title: const Text('Offline Map Cache'),
centerTitle: true,
),
body: Column(
children: [
Expanded(
child: Stack(
children: [
FlutterMap(
mapController: _mapController,
options: const MapOptions(
initialCenter: LatLng(0, 0),
initialZoom: 2.0,
minZoom: 2.0,
maxZoom: 18.0,
),
children: [
TileLayer(
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName:
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
if (selectedBounds != null)
PolygonLayer(
polygons: [
Polygon(
points: _boundsToPolygon(selectedBounds),
borderStrokeWidth: 2,
color: Colors.blue.withValues(alpha: 0.2),
borderColor: Colors.blue,
),
],
),
],
),
Positioned(
top: 12,
right: 12,
child: Card(
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
selectedBounds == null
? 'No area selected'
: _formatBounds(selectedBounds),
style: const TextStyle(fontSize: 12),
),
),
),
),
],
),
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Cache Area',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.crop_free),
label: const Text('Use Current View'),
onPressed: _isDownloading ? null : _setBoundsFromView,
),
),
const SizedBox(width: 12),
TextButton(
onPressed:
_isDownloading || selectedBounds == null ? null : _clearBounds,
child: const Text('Clear'),
),
],
),
const SizedBox(height: 12),
const Text(
'Zoom Range',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
RangeSlider(
values:
RangeValues(_minZoom.toDouble(), _maxZoom.toDouble()),
min: 3,
max: 18,
divisions: 15,
labels: RangeLabels('$_minZoom', '$_maxZoom'),
onChanged: _isDownloading
? null
: (values) {
setState(() {
_minZoom = values.start.round();
_maxZoom = values.end.round();
});
},
onChangeEnd: _isDownloading
? null
: (_) {
_saveZoomRange();
},
),
Text('Estimated tiles: $_estimatedTiles'),
if (_isDownloading) ...[
const SizedBox(height: 8),
LinearProgressIndicator(value: progressValue),
const SizedBox(height: 4),
Text('Downloaded $_completedTiles / $_estimatedTiles'),
],
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.download),
label: const Text('Download Tiles'),
onPressed: _isDownloading || selectedBounds == null
? null
: _startDownload,
),
),
const SizedBox(width: 12),
OutlinedButton(
onPressed: _isDownloading ? null : _clearCache,
child: const Text('Clear Cache'),
),
],
),
if (_failedTiles > 0 && !_isDownloading)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'Failed downloads: $_failedTiles',
style: TextStyle(color: Colors.orange[700]),
),
),
],
),
),
),
],
),
);
}
List<LatLng> _boundsToPolygon(LatLngBounds bounds) {
return [
bounds.northWest,
bounds.northEast,
bounds.southEast,
bounds.southWest,
];
}
String _formatBounds(LatLngBounds bounds) {
return 'N ${bounds.north.toStringAsFixed(4)}, '
'S ${bounds.south.toStringAsFixed(4)}, '
'E ${bounds.east.toStringAsFixed(4)}, '
'W ${bounds.west.toStringAsFixed(4)}';
}
}
+87 -4
View File
@@ -9,18 +9,27 @@ import '../models/channel.dart';
import '../models/contact.dart';
import '../services/app_settings_service.dart';
import '../services/map_marker_service.dart';
import '../services/map_tile_cache_service.dart';
import '../utils/contact_search.dart';
import '../utils/route_transitions.dart';
import '../widgets/quick_switch_bar.dart';
import 'channels_screen.dart';
import 'chat_screen.dart';
import 'contacts_screen.dart';
import 'settings_screen.dart';
class MapScreen extends StatefulWidget {
final LatLng? highlightPosition;
final String? highlightLabel;
final double highlightZoom;
final bool hideBackButton;
const MapScreen({
super.key,
this.highlightPosition,
this.highlightLabel,
this.highlightZoom = 15.0,
this.hideBackButton = false,
});
@override
@@ -60,6 +69,7 @@ class _MapScreenState extends State<MapScreen> {
Widget build(BuildContext context) {
return Consumer2<MeshCoreConnector, AppSettingsService>(
builder: (context, connector, settingsService, child) {
final tileCache = context.read<MapTileCacheService>();
final settings = settingsService.settings;
final contacts = connector.contacts;
final highlightPosition = widget.highlightPosition;
@@ -124,6 +134,22 @@ class _MapScreenState extends State<MapScreen> {
appBar: AppBar(
title: const Text('Node Map'),
centerTitle: true,
automaticallyImplyLeading: !widget.hideBackButton,
actions: [
IconButton(
icon: const Icon(Icons.tune),
tooltip: 'Settings',
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
),
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
onPressed: () => _disconnect(context, connector),
),
],
),
body: !hasMapContent
? _buildEmptyState()
@@ -173,8 +199,10 @@ class _MapScreenState extends State<MapScreen> {
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.meshcore.open',
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName:
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
MarkerLayer(
@@ -199,6 +227,13 @@ class _MapScreenState extends State<MapScreen> {
_buildLegend(contactsWithLocation.length, sharedMarkers.length),
],
),
bottomNavigationBar: SafeArea(
top: false,
child: QuickSwitchBar(
selectedIndex: 2,
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showFilterDialog(context, settingsService),
child: const Icon(Icons.filter_list),
@@ -556,6 +591,55 @@ class _MapScreenState extends State<MapScreen> {
);
}
void _handleQuickSwitch(int index, BuildContext context) {
if (index == 2) return;
switch (index) {
case 0:
Navigator.pushReplacement(
context,
buildQuickSwitchRoute(
const ContactsScreen(hideBackButton: true),
),
);
break;
case 1:
Navigator.pushReplacement(
context,
buildQuickSwitchRoute(
const ChannelsScreen(hideBackButton: true),
),
);
break;
}
}
Future<void> _disconnect(
BuildContext context,
MeshCoreConnector connector,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Disconnect'),
content: const Text('Are you sure you want to disconnect from this device?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Disconnect'),
),
],
),
);
if (confirmed == true) {
await connector.disconnect();
}
}
void _showMarkerInfo(_SharedMarker marker) {
showDialog(
context: context,
@@ -792,8 +876,7 @@ class _MapScreenState extends State<MapScreen> {
),
...allContacts
.where((contact) =>
query.isEmpty ||
contact.name.toLowerCase().contains(query))
query.isEmpty || matchesContactQuery(contact, query))
.map((contact) {
return ListTile(
leading: const Icon(Icons.person),
+2 -2
View File
@@ -4,7 +4,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../widgets/device_tile.dart';
import 'device_screen.dart';
import 'contacts_screen.dart';
/// Screen for scanning and connecting to MeshCore devices
class ScannerScreen extends StatelessWidget {
@@ -161,7 +161,7 @@ class ScannerScreen extends StatelessWidget {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DeviceScreen(),
builder: (context) => const ContactsScreen(),
),
);
}