Merge remote-tracking branch 'origin/dev' into gps-toggle-in-settings

# Conflicts:
#	lib/l10n/app_bg.arb
#	lib/l10n/app_de.arb
#	lib/l10n/app_es.arb
#	lib/l10n/app_fr.arb
#	lib/l10n/app_hu.arb
#	lib/l10n/app_it.arb
#	lib/l10n/app_ja.arb
#	lib/l10n/app_ko.arb
#	lib/l10n/app_localizations_es.dart
#	lib/l10n/app_localizations_it.dart
#	lib/l10n/app_localizations_nl.dart
#	lib/l10n/app_localizations_pt.dart
#	lib/l10n/app_localizations_sv.dart
#	lib/l10n/app_localizations_uk.dart
#	lib/l10n/app_nl.arb
#	lib/l10n/app_pl.arb
#	lib/l10n/app_pt.arb
#	lib/l10n/app_ru.arb
#	lib/l10n/app_sk.arb
#	lib/l10n/app_sl.arb
#	lib/l10n/app_sv.arb
#	lib/l10n/app_uk.arb
#	lib/l10n/app_zh.arb
This commit is contained in:
zjs81
2026-05-08 13:36:16 -07:00
77 changed files with 6783 additions and 1002 deletions
+9 -4
View File
@@ -1237,15 +1237,20 @@ class AppSettingsScreen extends StatelessWidget {
if (!context.mounted) return;
showDismissibleSnackBar(
context,
// TODO: l10n
content: Text('Deleted ${translationModelFriendlyName(model)}.'),
content: Text(
context.l10n.appSettings_translationModelDeleted(
translationModelFriendlyName(model),
),
),
);
} catch (error) {
if (!context.mounted) return;
showDismissibleSnackBar(
context,
content: Text('Delete failed: $error'),
); // TODO: l10n
content: Text(
context.l10n.appSettings_translationModelDeleteFailed('$error'),
),
);
}
}
+124 -55
View File
@@ -4,7 +4,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
@@ -13,7 +13,6 @@ import '../helpers/chat_scroll_controller.dart';
import '../connector/meshcore_protocol.dart';
import '../helpers/gif_helper.dart';
import '../helpers/reaction_helper.dart';
import '../helpers/utf8_length_limiter.dart';
import '../helpers/snack_bar_builder.dart';
import '../l10n/l10n.dart';
import '../models/channel.dart';
@@ -23,6 +22,7 @@ import '../services/app_settings_service.dart';
import '../services/chat_text_scale_service.dart';
import '../services/translation_service.dart';
import '../utils/emoji_utils.dart';
import '../widgets/byte_count_input.dart';
import '../widgets/chat_zoom_wrapper.dart';
import '../widgets/emoji_picker.dart';
import '../widgets/gif_message.dart';
@@ -32,13 +32,19 @@ import '../widgets/message_translation_button.dart';
import '../widgets/message_status_icon.dart';
import '../widgets/radio_stats_entry.dart';
import '../widgets/translated_message_content.dart';
import '../widgets/unread_divider.dart';
import 'channel_message_path_screen.dart';
import 'map_screen.dart';
class ChannelChatScreen extends StatefulWidget {
final Channel channel;
final int initialUnreadCount;
const ChannelChatScreen({super.key, required this.channel});
const ChannelChatScreen({
super.key,
required this.channel,
this.initialUnreadCount = 0,
});
@override
State<ChannelChatScreen> createState() => _ChannelChatScreenState();
@@ -55,32 +61,46 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
MeshCoreConnector? _connector;
DateTime? _lastChannelSendAt;
bool _channelSkipNextBottomSnap = false;
String? _unreadDividerMessageId;
String? _cachedFormatLocale;
late DateFormat _hmFormat;
late DateFormat _mdFormat;
@override
void initState() {
super.initState();
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
_scrollController.onScrollNearTop = _loadOlderMessages;
_scrollController.showJumpToBottom.addListener(_clearDividerAtBottom);
SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final connector = context.read<MeshCoreConnector>();
final settings = context.read<AppSettingsService>().settings;
final idx = widget.channel.index;
final unread = connector.getUnreadCountForChannelIndex(idx);
final unread = widget.initialUnreadCount;
final messages = connector.getChannelMessages(widget.channel);
ChannelMessage? anchor;
if (settings.jumpToOldestUnread && unread > 0) {
anchor = _findOldestUnreadChannelAnchor(
connector.getChannelMessages(widget.channel),
unread,
);
if (unread > 0) {
anchor = _findOldestUnreadChannelAnchor(messages, unread);
}
setState(() {
if (anchor != null) _unreadDividerMessageId = anchor.messageId;
});
connector.setActiveChannel(idx);
_connector = connector;
if (anchor != null) {
if (anchor != null && settings.jumpToOldestUnread) {
_channelSkipNextBottomSnap = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_scrollToMessage(anchor!.messageId);
_scrollController.jumpToEstimatedOffset(
unreadCount: unread,
totalMessages: messages.length,
onJumped: () {
if (!mounted) return;
_scrollToMessage(anchor!.messageId);
},
);
});
}
});
@@ -102,6 +122,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return oldest;
}
void _clearDividerAtBottom() {
if (!_scrollController.showJumpToBottom.value &&
_unreadDividerMessageId != null) {
setState(() => _unreadDividerMessageId = null);
}
}
void _onTextFieldFocusChange() {
if (_textFieldFocusNode.hasFocus && mounted) {
_scrollController.handleKeyboardOpen();
@@ -123,6 +150,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
@override
void dispose() {
_connector?.setActiveChannel(null);
_scrollController.showJumpToBottom.removeListener(_clearDividerAtBottom);
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
_textFieldFocusNode.dispose();
_textController.dispose();
@@ -321,6 +349,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
if (!_messageKeys.containsKey(message.messageId)) {
_messageKeys[message.messageId] = GlobalKey();
}
final isUnreadAnchor =
_unreadDividerMessageId != null &&
message.messageId == _unreadDividerMessageId;
return Container(
key: _messageKeys[message.messageId]!,
child: Builder(
@@ -329,10 +360,17 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
.select<ChatTextScaleService, double>(
(service) => service.scale,
);
return _buildMessageBubble(
final bubble = _buildMessageBubble(
message,
textScale,
);
if (isUnreadAnchor) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [const UnreadDivider(), bubble],
);
}
return bubble;
},
),
);
@@ -352,12 +390,24 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
}
void _markAsUnread(ChannelMessage message) {
final connector = context.read<MeshCoreConnector>();
final messages = connector.getChannelMessages(widget.channel);
var count = 0;
var found = false;
for (final m in messages) {
if (m.messageId == message.messageId) found = true;
if (found && !m.isOutgoing) count++;
}
connector.setChannelUnreadCount(widget.channel.index, count);
}
Widget _buildMessageBubble(ChannelMessage message, double textScale) {
final settingsService = context.watch<AppSettingsService>();
final enableTracing = settingsService.settings.enableMessageTracing;
final isOutgoing = message.isOutgoing;
final gifId = GifHelper.parseGif(message.text);
final poi = _parsePoiMessage(message.text);
final poi = parseMarkerText(message.text);
final translatedDisplayText =
message.translatedText != null &&
message.translatedText!.trim().isNotEmpty
@@ -445,6 +495,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
poi,
isOutgoing,
textScale,
message.senderName,
trailing: (!enableTracing && isOutgoing)
? Padding(
padding: const EdgeInsets.only(bottom: 2),
@@ -555,7 +606,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
'via ${_formatPathPrefixes(displayPath)}',
context.l10n.channels_via(
_formatPathPrefixes(displayPath),
),
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
@@ -576,7 +629,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message.timestamp),
_formatTime(context, message.timestamp),
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
@@ -701,7 +754,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final previewTextColor = colorScheme.onSurface.withValues(alpha: 0.7);
final gifId = GifHelper.parseGif(replyText);
final poi = _parsePoiMessage(replyText);
final poi = parseMarkerText(replyText);
Widget contentPreview;
if (gifId != null) {
@@ -812,24 +865,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
}
_PoiInfo? _parsePoiMessage(String text) {
final trimmed = text.trim();
final match = RegExp(
r'm:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|',
).firstMatch(trimmed);
if (match == null) return null;
final lat = double.tryParse(match.group(1) ?? '');
final lon = double.tryParse(match.group(2) ?? '');
if (lat == null || lon == null) return null;
final label = match.group(3) ?? '';
return _PoiInfo(lat: lat, lon: lon, label: label);
}
Widget _buildPoiMessage(
BuildContext context,
_PoiInfo poi,
MarkerPayload poi,
bool isOutgoing,
double textScale, {
double textScale,
String senderName, {
Widget? trailing,
}) {
final colorScheme = Theme.of(context).colorScheme;
@@ -849,12 +890,22 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () {
final selfName = context.read<MeshCoreConnector>().selfName ?? 'Me';
final fromName = isOutgoing ? selfName : senderName;
final key = buildSharedMarkerKey(
sourceId: 'channel:${widget.channel.index}',
label: poi.label,
fromName: fromName,
flags: poi.flags,
isChannel: true,
);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MapScreen(
highlightPosition: LatLng(poi.lat, poi.lon),
highlightPosition: poi.position,
highlightLabel: poi.label,
highlightMarkerKey: key,
),
),
);
@@ -1093,27 +1144,33 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
);
}
return TextField(
return ByteCountedTextField(
maxBytes: maxBytes,
controller: _textController,
focusNode: _textFieldFocusNode,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes),
],
textCapitalization: TextCapitalization.sentences,
hintText: context.l10n.chat_typeMessage,
onSubmitted: (_) => _sendMessage(),
encoder:
connector.isChannelSmazEnabled(widget.channel.index)
? (text) => connector.prepareChannelOutboundText(
widget.channel.index,
text,
)
: null,
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
filled: true,
fillColor: Theme.of(
context,
).colorScheme.surfaceContainerLow,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
horizontal: 20,
vertical: 14,
),
),
maxLines: null,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
);
},
),
@@ -1195,7 +1252,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}
final maxBytes = maxChannelMessageBytes(connector.selfName);
if (utf8.encode(messageText).length > maxBytes) {
final outboundText = connector.prepareChannelOutboundText(
widget.channel.index,
messageText,
);
if (utf8.encode(outboundText).length > maxBytes) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.chat_messageTooLong(maxBytes)),
@@ -1215,14 +1276,21 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
}
String _formatTime(DateTime time) {
String _formatTime(BuildContext context, DateTime time) {
final now = DateTime.now();
final diff = now.difference(time);
final locale = Localizations.localeOf(context).toString();
if (locale != _cachedFormatLocale) {
_cachedFormatLocale = locale;
_hmFormat = DateFormat.Hm(locale);
_mdFormat = DateFormat.Md(locale);
}
final hm = _hmFormat.format(time);
if (diff.inDays > 0) {
return '${time.day}/${time.month} ${time.hour}:${time.minute.toString().padLeft(2, '0')}';
return '${_mdFormat.format(time)} $hm';
} else {
return '${time.hour}:${time.minute.toString().padLeft(2, '0')}';
return hm;
}
}
@@ -1278,6 +1346,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_copyMessageText(message.text);
},
),
if (!message.isOutgoing)
ListTile(
leading: const Icon(Icons.mark_chat_unread_outlined),
title: Text(context.l10n.chat_markAsUnread),
onTap: () {
Navigator.pop(sheetContext);
_markAsUnread(message);
},
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: Text(context.l10n.common_delete),
@@ -1497,11 +1574,3 @@ class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> {
);
}
}
class _PoiInfo {
final double lat;
final double lon;
final String label;
const _PoiInfo({required this.lat, required this.lon, required this.label});
}
+98 -3
View File
@@ -1,6 +1,6 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
@@ -304,6 +304,8 @@ class ChannelMessagePathMapScreen extends StatefulWidget {
class _ChannelMessagePathMapScreenState
extends State<ChannelMessagePathMapScreen> {
static const double _labelZoomThreshold = 8.5;
static const double _mapMinZoom = 2.0;
static const double _mapMaxZoom = 18.0;
final MapController _mapController = MapController();
Uint8List? _selectedPath;
@@ -330,6 +332,18 @@ class _ChannelMessagePathMapScreenState
}
}
@override
void dispose() {
_mapController.dispose();
super.dispose();
}
bool _isDesktopPlatform(TargetPlatform platform) {
return platform == TargetPlatform.linux ||
platform == TargetPlatform.windows ||
platform == TargetPlatform.macOS;
}
double _getPathDistance(List<LatLng> points) {
double totalDistance = 0.0;
final distanceCalculator = Distance();
@@ -357,6 +371,70 @@ class _ChannelMessagePathMapScreenState
});
}
void _zoomMapBy(double delta) {
final camera = _mapController.camera;
final nextZoom = (camera.zoom + delta)
.clamp(_mapMinZoom, _mapMaxZoom)
.toDouble();
_mapController.move(camera.center, nextZoom);
}
void _resetMapView({
required LatLng initialCenter,
required double initialZoom,
required LatLngBounds? bounds,
}) {
if (bounds != null) {
_mapController.fitCamera(
CameraFit.bounds(
bounds: bounds,
padding: const EdgeInsets.all(64),
maxZoom: 16,
),
);
return;
}
_mapController.move(initialCenter, initialZoom);
}
Widget _buildDesktopMapControls({
required LatLng initialCenter,
required double initialZoom,
required LatLngBounds? bounds,
}) {
return Positioned(
left: 16,
top: 16,
child: Card(
elevation: 4,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.add),
tooltip: 'Zoom in',
onPressed: () => _zoomMapBy(1),
),
IconButton(
icon: const Icon(Icons.remove),
tooltip: 'Zoom out',
onPressed: () => _zoomMapBy(-1),
),
IconButton(
icon: const Icon(Icons.my_location),
tooltip: 'Center map',
onPressed: () => _resetMapView(
initialCenter: initialCenter,
initialZoom: initialZoom,
bounds: bounds,
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
@@ -372,6 +450,7 @@ class _ChannelMessagePathMapScreenState
primaryPath,
widget.message.pathVariants,
);
final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
final selectedPathTmp = _resolveSelectedPath(
_selectedPath,
observedPaths,
@@ -451,10 +530,20 @@ class _ChannelMessagePathMapScreenState
padding: const EdgeInsets.all(64),
maxZoom: 16,
),
minZoom: 2.0,
maxZoom: 18.0,
minZoom: _mapMinZoom,
maxZoom: _mapMaxZoom,
interactionOptions: InteractionOptions(
flags: ~InteractiveFlag.rotate,
scrollWheelVelocity: isDesktop ? 0.012 : 0.005,
cursorKeyboardRotationOptions:
CursorKeyboardRotationOptions.disabled(),
keyboardOptions: isDesktop
? const KeyboardOptions(
enableArrowKeysPanning: true,
enableWASDPanning: true,
enableRFZooming: true,
)
: const KeyboardOptions.disabled(),
),
onPositionChanged: (camera, hasGesture) {
final shouldShow = camera.zoom >= _labelZoomThreshold;
@@ -486,6 +575,12 @@ class _ChannelMessagePathMapScreenState
),
],
),
if (isDesktop)
_buildDesktopMapControls(
initialCenter: initialCenter,
initialZoom: initialZoom,
bounds: bounds,
),
if (observedPaths.length > 1)
_buildPathSelector(context, observedPaths, selectedIndex, (
index,
+10 -2
View File
@@ -492,13 +492,19 @@ class _ChannelsScreenState extends State<ChannelsScreen>
],
),
onTap: () async {
final unread = connector.getUnreadCountForChannelIndex(
channel.index,
);
connector.markChannelRead(channel.index);
await Future.delayed(const Duration(milliseconds: 50));
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChannelChatScreen(channel: channel),
builder: (context) => ChannelChatScreen(
channel: channel,
initialUnreadCount: unread,
),
),
);
}
@@ -1492,7 +1498,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
if (!context.mounted) return;
showDismissibleSnackBar(
context,
content: Text('Failed to update channel: $e'),
content: Text(
context.l10n.channels_channelUpdateFailed('$e'),
),
);
}
},
+142 -65
View File
@@ -9,7 +9,6 @@ import 'package:meshcore_open/screens/path_trace_map.dart';
import 'package:provider/provider.dart';
import '../utils/platform_info.dart';
import 'package:latlong2/latlong.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
@@ -18,9 +17,9 @@ import '../widgets/message_status_icon.dart';
import '../helpers/chat_scroll_controller.dart';
import '../helpers/gif_helper.dart';
import '../helpers/path_helper.dart';
import '../helpers/utf8_length_limiter.dart';
import '../models/channel_message.dart';
import '../models/contact.dart';
import '../l10n/contact_localization.dart';
import '../models/message.dart';
import '../models/path_history.dart';
import '../models/translation_support.dart';
@@ -30,6 +29,7 @@ import '../services/path_history_service.dart';
import '../services/translation_service.dart';
import '../widgets/chat_zoom_wrapper.dart';
import '../widgets/elements_ui.dart';
import '../widgets/byte_count_input.dart';
import 'channel_message_path_screen.dart';
import 'map_screen.dart';
import '../utils/emoji_utils.dart';
@@ -44,12 +44,18 @@ import '../widgets/translated_message_content.dart';
import '../utils/app_logger.dart';
import '../l10n/l10n.dart';
import '../helpers/snack_bar_builder.dart';
import '../widgets/unread_divider.dart';
import 'telemetry_screen.dart';
class ChatScreen extends StatefulWidget {
final Contact contact;
final int initialUnreadCount;
const ChatScreen({super.key, required this.contact});
const ChatScreen({
super.key,
required this.contact,
this.initialUnreadCount = 0,
});
@override
State<ChatScreen> createState() => _ChatScreenState();
@@ -63,6 +69,7 @@ class _ChatScreenState extends State<ChatScreen> {
bool _isLoadingOlder = false;
MeshCoreConnector? _connector;
Message? _pendingUnreadScrollTarget;
String? _unreadDividerMessageId;
DateTime? _lastTextSendAt;
@override
@@ -70,34 +77,47 @@ class _ChatScreenState extends State<ChatScreen> {
super.initState();
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
_scrollController.onScrollNearTop = _loadOlderMessages;
_scrollController.showJumpToBottom.addListener(_clearDividerAtBottom);
SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final connector = context.read<MeshCoreConnector>();
final settings = context.read<AppSettingsService>().settings;
final keyHex = widget.contact.publicKeyHex;
final unread = connector.getUnreadCountForContactKey(keyHex);
final unread = widget.initialUnreadCount;
final messages = connector.getMessages(widget.contact);
Message? anchor;
if (settings.jumpToOldestUnread && unread > 0) {
anchor = _findOldestUnreadAnchor(
connector.getMessages(widget.contact),
unread,
);
if (unread > 0) {
anchor = _findOldestUnreadAnchor(messages, unread);
}
setState(() {
if (anchor != null) _unreadDividerMessageId = anchor.messageId;
if (anchor != null && settings.jumpToOldestUnread) {
_pendingUnreadScrollTarget = anchor;
}
});
connector.setActiveContact(keyHex);
_connector = connector;
if (anchor != null) {
setState(() => _pendingUnreadScrollTarget = anchor);
if (anchor != null && settings.jumpToOldestUnread) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final ctx = _unreadScrollKey.currentContext;
if (ctx != null) {
Scrollable.ensureVisible(
ctx,
duration: const Duration(milliseconds: 350),
alignment: 0.15,
);
}
setState(() => _pendingUnreadScrollTarget = null);
_scrollController.jumpToEstimatedOffset(
unreadCount: unread,
totalMessages: messages.length,
onJumped: () async {
if (!mounted) return;
final ctx = _unreadScrollKey.currentContext;
if (ctx != null) {
await Scrollable.ensureVisible(
ctx,
duration: const Duration(milliseconds: 350),
alignment: 0.15,
);
}
if (mounted) {
setState(() => _pendingUnreadScrollTarget = null);
}
},
);
});
}
});
@@ -116,6 +136,13 @@ class _ChatScreenState extends State<ChatScreen> {
return oldest;
}
void _clearDividerAtBottom() {
if (!_scrollController.showJumpToBottom.value &&
_unreadDividerMessageId != null) {
setState(() => _unreadDividerMessageId = null);
}
}
void _onTextFieldFocusChange() {
if (_textFieldFocusNode.hasFocus && mounted) {
_scrollController.handleKeyboardOpen();
@@ -137,6 +164,7 @@ class _ChatScreenState extends State<ChatScreen> {
@override
void dispose() {
_connector?.setActiveContact(null);
_scrollController.showJumpToBottom.removeListener(_clearDividerAtBottom);
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
_textFieldFocusNode.dispose();
_textController.dispose();
@@ -479,6 +507,7 @@ class _ChatScreenState extends State<ChatScreen> {
senderName: resolvedContact.type == advTypeRoom
? "${contact.name} [$fourByteHex]"
: contact.name,
sourceId: widget.contact.publicKeyHex,
isRoomServer: resolvedContact.type == advTypeRoom,
textScale: textScale,
onTap: () => _openMessagePath(message, contact),
@@ -486,10 +515,19 @@ class _ChatScreenState extends State<ChatScreen> {
onRetryReaction: (msg, emoji) =>
_sendReaction(msg, contact, emoji),
);
final isUnreadAnchor =
_unreadDividerMessageId != null &&
message.messageId == _unreadDividerMessageId;
final child = isUnreadAnchor
? Column(
mainAxisSize: MainAxisSize.min,
children: [const UnreadDivider(), bubble],
)
: bubble;
if (identical(message, _pendingUnreadScrollTarget)) {
return KeyedSubtree(key: _unreadScrollKey, child: bubble);
return KeyedSubtree(key: _unreadScrollKey, child: child);
}
return bubble;
return child;
},
);
},
@@ -497,6 +535,18 @@ class _ChatScreenState extends State<ChatScreen> {
);
}
void _markAsUnread(Message message) {
final connector = context.read<MeshCoreConnector>();
final messages = connector.getMessages(widget.contact);
var count = 0;
var found = false;
for (final m in messages) {
if (m.messageId == message.messageId) found = true;
if (found && !m.isOutgoing && !m.isCli) count++;
}
connector.setContactUnreadCount(widget.contact.publicKeyHex, count);
}
Widget _buildInputBar(MeshCoreConnector connector) {
final maxBytes = maxContactMessageBytes();
final colorScheme = Theme.of(context).colorScheme;
@@ -567,24 +617,35 @@ class _ChatScreenState extends State<ChatScreen> {
),
);
}
return TextField(
return ByteCountedTextField(
maxBytes: maxBytes,
controller: _textController,
focusNode: _textFieldFocusNode,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes),
],
textCapitalization: TextCapitalization.sentences,
hintText: context.l10n.chat_typeMessage,
onSubmitted: (_) => _sendMessage(connector),
encoder:
connector.isContactSmazEnabled(
widget.contact.publicKeyHex,
)
? (text) => connector.prepareContactOutboundText(
widget.contact,
text,
)
: null,
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: const OutlineInputBorder(),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
filled: true,
fillColor: Theme.of(
context,
).colorScheme.surfaceContainerLow,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
horizontal: 20,
vertical: 14,
),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(connector),
);
},
),
@@ -672,7 +733,11 @@ class _ChatScreenState extends State<ChatScreen> {
}
}
final maxBytes = maxContactMessageBytes();
if (utf8.encode(outgoingText).length > maxBytes) {
final outboundText = connector.prepareContactOutboundText(
_resolveContact(connector),
outgoingText,
);
if (utf8.encode(outboundText).length > maxBytes) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.chat_messageTooLong(maxBytes)),
@@ -1153,8 +1218,14 @@ class _ChatScreenState extends State<ChatScreen> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow(context.l10n.chat_type, contact.typeLabel),
_buildInfoRow(context.l10n.chat_path, contact.pathLabel),
_buildInfoRow(
context.l10n.chat_type,
contact.typeLabel(context.l10n),
),
_buildInfoRow(
context.l10n.chat_path,
contact.pathLabel(context.l10n),
),
_buildInfoRow(
context.l10n.contact_lastSeen,
_formatContactLastMessage(contact.lastMessageAt),
@@ -1305,11 +1376,15 @@ class _ChatScreenState extends State<ChatScreen> {
}
void _openChat(BuildContext context, Contact contact) {
// Check if this is a repeater
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
final connector = context.read<MeshCoreConnector>();
final unread = connector.getUnreadCountForContactKey(contact.publicKeyHex);
connector.markContactRead(contact.publicKeyHex);
Navigator.push(
context,
MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)),
MaterialPageRoute(
builder: (context) =>
ChatScreen(contact: contact, initialUnreadCount: unread),
),
);
}
@@ -1446,6 +1521,15 @@ class _ChatScreenState extends State<ChatScreen> {
_copyMessageText(message.text);
},
),
if (!message.isOutgoing)
ListTile(
leading: const Icon(Icons.mark_chat_unread_outlined),
title: Text(context.l10n.chat_markAsUnread),
onTap: () {
Navigator.pop(sheetContext);
_markAsUnread(message);
},
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: Text(context.l10n.common_delete),
@@ -1553,10 +1637,12 @@ class _MessageBubble extends StatelessWidget {
final VoidCallback? onLongPress;
final void Function(Message message, String emoji)? onRetryReaction;
final double textScale;
final String sourceId;
const _MessageBubble({
required this.message,
required this.senderName,
required this.sourceId,
required this.isRoomServer,
required this.textScale,
this.onTap,
@@ -1571,7 +1657,7 @@ class _MessageBubble extends StatelessWidget {
final isOutgoing = message.isOutgoing;
final colorScheme = Theme.of(context).colorScheme;
final gifId = GifHelper.parseGif(message.text);
final poi = _parsePoiMessage(message.text);
final poi = parseMarkerText(message.text);
final isFailed = message.status == MessageStatus.failed;
final bubbleColor = isFailed
? colorScheme.errorContainer
@@ -1663,6 +1749,7 @@ class _MessageBubble extends StatelessWidget {
textColor,
metaColor,
textScale,
senderName,
trailing: (!enableTracing && isOutgoing)
? Padding(
padding: const EdgeInsets.only(bottom: 2),
@@ -1844,25 +1931,13 @@ class _MessageBubble extends StatelessWidget {
);
}
_PoiInfo? _parsePoiMessage(String text) {
final trimmed = text.trim();
final match = RegExp(
r'^m:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|.*$',
).firstMatch(trimmed);
if (match == null) return null;
final lat = double.tryParse(match.group(1) ?? '');
final lon = double.tryParse(match.group(2) ?? '');
if (lat == null || lon == null) return null;
final label = match.group(3) ?? '';
return _PoiInfo(lat: lat, lon: lon, label: label);
}
Widget _buildPoiMessage(
BuildContext context,
_PoiInfo poi,
MarkerPayload poi,
Color textColor,
Color metaColor,
double textScale, {
double textScale,
String senderName, {
Widget? trailing,
}) {
return Row(
@@ -1872,13 +1947,23 @@ class _MessageBubble extends StatelessWidget {
icon: Icon(Icons.location_on_outlined, color: textColor),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () {
onPressed: () async {
final selfName = context.read<MeshCoreConnector>().selfName ?? 'Me';
final fromName = message.isOutgoing ? selfName : senderName;
final key = buildSharedMarkerKey(
sourceId: sourceId,
label: poi.label,
fromName: fromName,
flags: poi.flags,
isChannel: false,
);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MapScreen(
highlightPosition: LatLng(poi.lat, poi.lon),
highlightPosition: poi.position,
highlightLabel: poi.label,
highlightMarkerKey: key,
),
),
);
@@ -2059,11 +2144,3 @@ class _MessageBubble extends StatelessWidget {
return '$hour:$minute';
}
}
class _PoiInfo {
final double lat;
final double lon;
final String label;
const _PoiInfo({required this.lat, required this.lon, required this.label});
}
+20 -6
View File
@@ -13,6 +13,7 @@ import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../connector/meshcore_protocol.dart';
import '../models/contact.dart';
import '../l10n/contact_localization.dart';
import '../models/contact_group.dart';
import '../services/ui_view_state_service.dart';
import '../utils/contact_search.dart';
@@ -930,10 +931,17 @@ class _ContactsScreenState extends State<ContactsScreen>
} else if (contact.type == advTypeRoom) {
_showRoomLogin(context, contact, RoomLoginDestination.chat);
} else {
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
final connector = context.read<MeshCoreConnector>();
final unread = connector.getUnreadCountForContactKey(
contact.publicKeyHex,
);
connector.markContactRead(contact.publicKeyHex);
Navigator.push(
context,
MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)),
MaterialPageRoute(
builder: (context) =>
ChatScreen(contact: contact, initialUnreadCount: unread),
),
);
}
}
@@ -988,7 +996,11 @@ class _ContactsScreenState extends State<ContactsScreen>
builder: (context) => RoomLoginDialog(
room: room,
onLogin: (password, isAdmin) {
context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex);
final connector = context.read<MeshCoreConnector>();
final unread = connector.getUnreadCountForContactKey(
room.publicKeyHex,
);
connector.markContactRead(room.publicKeyHex);
Navigator.push(
context,
MaterialPageRoute(
@@ -999,7 +1011,7 @@ class _ContactsScreenState extends State<ContactsScreen>
password: password,
isAdmin: isAdmin,
)
: ChatScreen(contact: room),
: ChatScreen(contact: room, initialUnreadCount: unread),
),
);
},
@@ -1122,7 +1134,9 @@ class _ContactsScreenState extends State<ContactsScreen>
return CheckboxListTile(
value: isSelected,
title: Text(contact.name),
subtitle: Text(contact.typeLabel),
subtitle: Text(
contact.typeLabel(context.l10n),
),
onChanged: (value) {
setDialogState(() {
if (value == true) {
@@ -1464,7 +1478,7 @@ class _ContactTile extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
contact.pathLabel,
contact.pathLabel(context.l10n),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
+497 -28
View File
@@ -1,6 +1,7 @@
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
@@ -56,12 +57,16 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
static const double _maxAntennaFeet = 400.0;
static const double _maxAntennaMeters = _maxAntennaFeet / _metersToFeet;
static const double _labelZoomThreshold = 8.5;
static const double _mapMinZoom = 2.0;
static const double _mapMaxZoom = 18.0;
final LineOfSightService _lineOfSightService = LineOfSightService();
final MapController _mapController = MapController();
bool _loading = false;
String? _error;
LineOfSightPathResult? _result;
LineOfSightObstruction? _selectedObstruction;
LineOfSightEndpoint? _start;
LineOfSightEndpoint? _end;
final List<LineOfSightEndpoint> _customEndpoints = [];
@@ -98,10 +103,85 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
@override
void dispose() {
_mapController.dispose();
_lineOfSightService.dispose();
super.dispose();
}
bool _isDesktopPlatform(TargetPlatform platform) {
return platform == TargetPlatform.linux ||
platform == TargetPlatform.windows ||
platform == TargetPlatform.macOS;
}
void _zoomMapBy(double delta) {
final camera = _mapController.camera;
final nextZoom = (camera.zoom + delta)
.clamp(_mapMinZoom, _mapMaxZoom)
.toDouble();
_mapController.move(camera.center, nextZoom);
}
void _resetMapView({
required LatLng initialCenter,
required double initialZoom,
required LatLngBounds? bounds,
}) {
if (bounds != null) {
_mapController.fitCamera(
CameraFit.bounds(
bounds: bounds,
padding: const EdgeInsets.all(64),
maxZoom: 16,
),
);
return;
}
_mapController.move(initialCenter, initialZoom);
}
Widget _buildDesktopMapControls({
required LatLng initialCenter,
required double initialZoom,
required LatLngBounds? bounds,
}) {
final screenHeight = MediaQuery.of(context).size.height;
final topOffset = _showHud
? math.min(screenHeight * 0.52 + 24, screenHeight - 220)
: 12.0;
return Positioned(
top: topOffset,
left: 12,
child: Card(
elevation: 4,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.add),
tooltip: 'Zoom in',
onPressed: () => _zoomMapBy(1),
),
IconButton(
icon: const Icon(Icons.remove),
tooltip: 'Zoom out',
onPressed: () => _zoomMapBy(-1),
),
IconButton(
icon: const Icon(Icons.my_location),
tooltip: 'Center map',
onPressed: () => _resetMapView(
initialCenter: initialCenter,
initialZoom: initialZoom,
bounds: bounds,
),
),
],
),
),
);
}
Future<void> _runLos() async {
final start = _start;
final end = _end;
@@ -111,6 +191,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
if (start == null || end == null) {
setState(() {
_result = null;
_selectedObstruction = null;
_error = _errorSelectStartEnd;
});
return;
@@ -142,6 +223,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
}
setState(() {
_result = result;
_selectedObstruction = _defaultObstructionFor(result);
});
} catch (e) {
if (!mounted) return;
@@ -156,6 +238,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
}
setState(() {
_result = null;
_selectedObstruction = null;
_error = context.l10n.losRunFailed(e.toString());
});
} finally {
@@ -184,6 +267,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
void _selectFromMap(LineOfSightEndpoint endpoint) {
setState(() {
_result = null;
_selectedObstruction = null;
_error = null;
if (_start == null || (_start != null && _end != null)) {
_start = endpoint;
@@ -241,6 +325,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
_start = null;
_end = null;
_result = null;
_selectedObstruction = null;
_error = _errorSelectStartEnd;
});
}
@@ -251,6 +336,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
if (identical(_start, endpoint)) _start = null;
if (identical(_end, endpoint)) _end = null;
_result = null;
_selectedObstruction = null;
});
}
@@ -318,6 +404,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
? LatLngBounds.fromPoints(mapPoints)
: null;
final initialZoom = mapPoints.length > 1 ? 13.0 : 2.0;
final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
if (!_didReceivePositionUpdate) {
_showMarkerLabels = initialZoom >= _labelZoomThreshold;
}
@@ -343,6 +430,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
body: Stack(
children: [
FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: initialCenter,
initialZoom: initialZoom,
@@ -355,7 +443,19 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
),
interactionOptions: InteractionOptions(
flags: ~InteractiveFlag.rotate,
scrollWheelVelocity: isDesktop ? 0.012 : 0.005,
cursorKeyboardRotationOptions:
CursorKeyboardRotationOptions.disabled(),
keyboardOptions: isDesktop
? const KeyboardOptions(
enableArrowKeysPanning: true,
enableWASDPanning: true,
enableRFZooming: true,
)
: const KeyboardOptions.disabled(),
),
minZoom: _mapMinZoom,
maxZoom: _mapMaxZoom,
onLongPress: (_, point) => _addCustomPoint(point),
onPositionChanged: (camera, hasGesture) {
final shouldShow = camera.zoom >= _labelZoomThreshold;
@@ -377,9 +477,17 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
),
if (_result != null && _result!.segments.isNotEmpty)
PolylineLayer(polylines: _buildSegmentPolylines(_result!)),
MarkerLayer(markers: _buildMarkers(endpoints)),
MarkerLayer(
markers: _buildMarkers(endpoints, _primaryObstructions()),
),
],
),
if (isDesktop)
_buildDesktopMapControls(
initialCenter: initialCenter,
initialZoom: initialZoom,
bounds: bounds,
),
if (_showHud)
Positioned(
left: 12,
@@ -445,6 +553,8 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
);
final displayFrequencyMHz = segment?.frequencyMHz ?? reportedFrequencyMHz;
final kFactorUsed = segment?.usedKFactor;
final obstructions =
segment?.obstructions ?? const <LineOfSightObstruction>[];
final endpoints = _visibleEndpoints();
final distanceUnit = isImperial ? 'mi' : 'km';
final heightUnit = isImperial ? 'ft' : 'm';
@@ -463,31 +573,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (segment != null)
SizedBox(
height: 160,
width: double.infinity,
child: CustomPaint(
painter: _LosProfilePainter(
samples: segment.samples,
distanceUnit: distanceUnit,
heightUnit: heightUnit,
badgeTextStyle:
Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.white70,
fontSize: 10,
fontWeight: FontWeight.w600,
) ??
const TextStyle(
color: Colors.white70,
fontSize: 10,
fontWeight: FontWeight.w600,
),
terrainLabel: context.l10n.losLegendTerrain,
losBeamLabel: context.l10n.losLegendLosBeam,
radioHorizonLabel: context.l10n.losLegendRadioHorizon,
),
),
)
_buildProfileView(segment, distanceUnit, heightUnit, isImperial)
else
SizedBox(
height: 44,
@@ -519,6 +605,96 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
fontWeight: FontWeight.w600,
),
),
if (obstructions.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
context.l10n.losBlockedSpotsTitle,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
context.l10n.losBlockedSpotsHint,
style: TextStyle(fontSize: 11, color: Colors.grey[700]),
),
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final obstruction in obstructions)
ChoiceChip(
label: Text(
_obstructionChipLabel(obstruction, isImperial),
style: const TextStyle(fontSize: 11),
),
selected:
_selectedObstruction?.sampleIndex ==
obstruction.sampleIndex,
onSelected: (_) => _selectObstruction(obstruction),
),
],
),
if (_selectedObstruction != null) ...[
const SizedBox(height: 8),
DecoratedBox(
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Colors.deepOrangeAccent.withValues(alpha: 0.45),
),
),
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.losSelectedObstructionTitle,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
context.l10n.losSelectedObstructionDetails(
_formatHeightValue(
_selectedObstruction!.obstructionMeters,
isImperial,
),
heightUnit,
_formatDistanceValue(
_selectedObstruction!.distanceMeters,
isImperial,
),
distanceUnit,
_formatDistanceValue(
segment!.totalDistanceMeters -
_selectedObstruction!.distanceMeters,
isImperial,
),
),
style: const TextStyle(fontSize: 11),
),
const SizedBox(height: 4),
Text(
'${_selectedObstruction!.point.latitude.toStringAsFixed(5)}, '
'${_selectedObstruction!.point.longitude.toStringAsFixed(5)}',
style: TextStyle(
fontSize: 11,
color: Colors.grey[700],
),
),
],
),
),
),
],
],
const SizedBox(height: 4),
if (displayFrequencyMHz != null)
Padding(
@@ -605,6 +781,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
_showDisplayNodes = value;
_sanitizeSelection();
_result = null;
_selectedObstruction = null;
});
},
),
@@ -655,6 +832,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
setState(() {
_start = value;
_result = null;
_selectedObstruction = null;
});
if (_start != null && _end != null) {
_runLos();
@@ -670,6 +848,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
setState(() {
_end = value;
_result = null;
_selectedObstruction = null;
});
if (_start != null && _end != null) {
_runLos();
@@ -769,6 +948,179 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
return _result!.segments.first.result;
}
List<LineOfSightObstruction> _primaryObstructions() {
return _primarySegmentResult()?.obstructions ?? const [];
}
LineOfSightObstruction? _defaultObstructionFor(
LineOfSightPathResult? result,
) {
if (result == null || result.segments.isEmpty) return null;
final obstructions = result.segments.first.result.obstructions;
if (obstructions.isEmpty) return null;
return obstructions.reduce(
(current, next) =>
next.obstructionMeters > current.obstructionMeters ? next : current,
);
}
void _selectObstruction(LineOfSightObstruction obstruction) {
setState(() {
_selectedObstruction = obstruction;
});
}
String _formatDistanceValue(double meters, bool isImperial) {
final value = isImperial ? (meters / 1000.0) * _kmToMiles : meters / 1000.0;
return value.toStringAsFixed(2);
}
String _formatHeightValue(double meters, bool isImperial) {
final value = isImperial ? meters * _metersToFeet : meters;
return value.toStringAsFixed(1);
}
String _obstructionChipLabel(
LineOfSightObstruction obstruction,
bool isImperial,
) {
final distanceUnit = isImperial ? 'mi' : 'km';
final heightUnit = isImperial ? 'ft' : 'm';
return context.l10n.losBlockedSpotChip(
_formatDistanceValue(obstruction.distanceMeters, isImperial),
distanceUnit,
_formatHeightValue(obstruction.obstructionMeters, isImperial),
heightUnit,
);
}
Widget _buildProfileView(
LineOfSightResult segment,
String distanceUnit,
String heightUnit,
bool isImperial,
) {
if (segment.samples.length < 2) {
return SizedBox(
height: 160,
width: double.infinity,
child: CustomPaint(
painter: _LosProfilePainter(
samples: segment.samples,
distanceUnit: distanceUnit,
heightUnit: heightUnit,
badgeTextStyle:
Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.white70,
fontSize: 10,
fontWeight: FontWeight.w600,
) ??
const TextStyle(
color: Colors.white70,
fontSize: 10,
fontWeight: FontWeight.w600,
),
terrainLabel: context.l10n.losLegendTerrain,
losBeamLabel: context.l10n.losLegendLosBeam,
radioHorizonLabel: context.l10n.losLegendRadioHorizon,
selectedSampleIndex: _selectedObstruction?.sampleIndex,
),
),
);
}
return SizedBox(
height: 160,
width: double.infinity,
child: LayoutBuilder(
builder: (context, constraints) {
final size = Size(constraints.maxWidth, 160);
final geometry = _LosProfileGeometry(
samples: segment.samples,
size: size,
);
return Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: CustomPaint(
painter: _LosProfilePainter(
samples: segment.samples,
distanceUnit: distanceUnit,
heightUnit: heightUnit,
badgeTextStyle:
Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.white70,
fontSize: 10,
fontWeight: FontWeight.w600,
) ??
const TextStyle(
color: Colors.white70,
fontSize: 10,
fontWeight: FontWeight.w600,
),
terrainLabel: context.l10n.losLegendTerrain,
losBeamLabel: context.l10n.losLegendLosBeam,
radioHorizonLabel: context.l10n.losLegendRadioHorizon,
selectedSampleIndex: _selectedObstruction?.sampleIndex,
),
),
),
for (final obstruction in segment.obstructions)
Builder(
builder: (context) {
final sample = segment.samples[obstruction.sampleIndex];
final position = geometry.mapPoint(
sample.distanceMeters,
sample.terrainMeters,
);
final isSelected =
_selectedObstruction?.sampleIndex ==
obstruction.sampleIndex;
final markerSize = isSelected ? 18.0 : 14.0;
final left = (position.dx - markerSize / 2)
.clamp(0.0, math.max(0.0, size.width - markerSize))
.toDouble();
final top = (position.dy - markerSize / 2)
.clamp(0.0, math.max(0.0, size.height - markerSize))
.toDouble();
return Positioned(
left: left,
top: top,
child: Tooltip(
message: _obstructionChipLabel(obstruction, isImperial),
child: GestureDetector(
onTap: () => _selectObstruction(obstruction),
child: Container(
width: markerSize,
height: markerSize,
decoration: BoxDecoration(
color: isSelected
? Colors.amberAccent
: Colors.deepOrangeAccent,
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? Colors.white
: Colors.black87,
width: isSelected ? 2 : 1.5,
),
boxShadow: const [
BoxShadow(color: Colors.black45, blurRadius: 4),
],
),
),
),
),
);
},
),
],
);
},
),
);
}
String _profileStats(LineOfSightResult result, bool isImperial) {
final distance = isImperial
? (result.totalDistanceMeters / 1000.0) * _kmToMiles
@@ -820,8 +1172,51 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
return polylines;
}
List<Marker> _buildMarkers(List<LineOfSightEndpoint> endpoints) {
List<Marker> _buildMarkers(
List<LineOfSightEndpoint> endpoints,
List<LineOfSightObstruction> obstructions,
) {
return [
for (final obstruction in obstructions)
Marker(
point: obstruction.point,
width: 52,
height: 52,
child: GestureDetector(
onTap: () => _selectObstruction(obstruction),
child: Center(
child: Container(
width:
_selectedObstruction?.sampleIndex == obstruction.sampleIndex
? 36
: 24,
height:
_selectedObstruction?.sampleIndex == obstruction.sampleIndex
? 36
: 24,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.transparent,
border: Border.all(
color:
_selectedObstruction?.sampleIndex ==
obstruction.sampleIndex
? Colors.amberAccent
: Colors.deepOrangeAccent,
width:
_selectedObstruction?.sampleIndex ==
obstruction.sampleIndex
? 4
: 3,
),
boxShadow: const [
BoxShadow(color: Colors.black26, blurRadius: 6),
],
),
),
),
),
),
for (final endpoint in endpoints)
Marker(
point: endpoint.point,
@@ -1010,6 +1405,51 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
}
}
class _LosProfileGeometry {
static const horizontalPadding = 12.0;
static const verticalPadding = 12.0;
final List<LineOfSightSample> samples;
final Size size;
late final double minY = samples
.map(
(s) => math.min(
math.min(s.terrainMeters, s.lineHeightMeters),
s.refractedHeightMeters,
),
)
.reduce(math.min);
late final double maxY = samples
.map(
(s) => math.max(
math.max(s.terrainMeters, s.lineHeightMeters),
s.refractedHeightMeters,
),
)
.reduce(math.max);
late final double ySpan = math.max(1.0, maxY - minY);
late final double maxDist = math.max(1.0, samples.last.distanceMeters);
late final double chartWidth = math.max(
1.0,
size.width - horizontalPadding * 2,
);
late final double chartHeight = math.max(
1.0,
size.height - verticalPadding * 2,
);
_LosProfileGeometry({required this.samples, required this.size});
Offset mapPoint(double distanceMeters, double elevationMeters) {
final px = horizontalPadding + (distanceMeters / maxDist) * chartWidth;
final py =
size.height -
verticalPadding -
((elevationMeters - minY) / ySpan) * chartHeight;
return Offset(px, py);
}
}
class _LosProfilePainter extends CustomPainter {
final List<LineOfSightSample> samples;
final String distanceUnit;
@@ -1018,6 +1458,7 @@ class _LosProfilePainter extends CustomPainter {
final String terrainLabel;
final String losBeamLabel;
final String radioHorizonLabel;
final int? selectedSampleIndex;
const _LosProfilePainter({
required this.samples,
@@ -1027,6 +1468,7 @@ class _LosProfilePainter extends CustomPainter {
required this.terrainLabel,
required this.losBeamLabel,
required this.radioHorizonLabel,
this.selectedSampleIndex,
});
@override
@@ -1212,6 +1654,32 @@ class _LosProfilePainter extends CustomPainter {
..color = horizonFillColor
..style = PaintingStyle.fill,
);
if (selectedSampleIndex != null &&
selectedSampleIndex! >= 0 &&
selectedSampleIndex! < samples.length) {
final selectedSample = samples[selectedSampleIndex!];
final selectedPoint = mapPoint(
selectedSample.distanceMeters,
selectedSample.terrainMeters,
);
canvas.drawLine(
Offset(selectedPoint.dx, verticalPadding),
Offset(selectedPoint.dx, size.height - verticalPadding),
Paint()
..color = Colors.amberAccent.withValues(alpha: 0.7)
..strokeWidth = 1.5,
);
canvas.drawCircle(selectedPoint, 7, Paint()..color = Colors.amberAccent);
canvas.drawCircle(
selectedPoint,
8.5,
Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 1.5,
);
}
}
@override
@@ -1222,7 +1690,8 @@ class _LosProfilePainter extends CustomPainter {
oldDelegate.badgeTextStyle != badgeTextStyle ||
oldDelegate.terrainLabel != terrainLabel ||
oldDelegate.losBeamLabel != losBeamLabel ||
oldDelegate.radioHorizonLabel != radioHorizonLabel;
oldDelegate.radioHorizonLabel != radioHorizonLabel ||
oldDelegate.selectedSampleIndex != selectedSampleIndex;
}
void _drawUnitBadge(Canvas canvas, Size size) {
+78 -4
View File
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
@@ -18,6 +19,9 @@ class MapCacheScreen extends StatefulWidget {
}
class _MapCacheScreenState extends State<MapCacheScreen> {
static const double _mapMinZoom = 2.0;
static const double _mapMaxZoom = 18.0;
final MapController _mapController = MapController();
LatLngBounds? _selectedBounds;
@@ -43,6 +47,61 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
super.dispose();
}
bool _isDesktopPlatform(TargetPlatform platform) {
return platform == TargetPlatform.linux ||
platform == TargetPlatform.windows ||
platform == TargetPlatform.macOS;
}
void _zoomMapBy(double delta) {
final camera = _mapController.camera;
final nextZoom = (camera.zoom + delta)
.clamp(_mapMinZoom, _mapMaxZoom)
.toDouble();
_mapController.move(camera.center, nextZoom);
}
void _resetMapView() {
final bounds = _selectedBounds;
if (bounds != null) {
_mapController.fitCamera(
CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(48)),
);
return;
}
_mapController.move(const LatLng(0, 0), 2.0);
}
Widget _buildDesktopMapControls() {
return Positioned(
top: 12,
left: 12,
child: Card(
elevation: 4,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.add),
tooltip: 'Zoom in',
onPressed: () => _zoomMapBy(1),
),
IconButton(
icon: const Icon(Icons.remove),
tooltip: 'Zoom out',
onPressed: () => _zoomMapBy(-1),
),
IconButton(
icon: const Icon(Icons.my_location),
tooltip: 'Center map',
onPressed: _resetMapView,
),
],
),
),
);
}
void _loadSettings() {
final settings = context.read<AppSettingsService>().settings;
final bounds = MapTileCacheService.boundsFromJson(settings.mapCacheBounds);
@@ -222,6 +281,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
final tileCache = context.read<MapTileCacheService>();
final selectedBounds = _selectedBounds;
final l10n = context.l10n;
final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
final progressValue = _estimatedTiles == 0
? 0.0
: (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble();
@@ -238,11 +298,24 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
children: [
FlutterMap(
mapController: _mapController,
options: const MapOptions(
initialCenter: LatLng(0, 0),
options: MapOptions(
initialCenter: const LatLng(0, 0),
initialZoom: 2.0,
minZoom: 2.0,
maxZoom: 18.0,
minZoom: _mapMinZoom,
maxZoom: _mapMaxZoom,
interactionOptions: InteractionOptions(
flags: ~InteractiveFlag.rotate,
scrollWheelVelocity: isDesktop ? 0.012 : 0.005,
cursorKeyboardRotationOptions:
CursorKeyboardRotationOptions.disabled(),
keyboardOptions: isDesktop
? const KeyboardOptions(
enableArrowKeysPanning: true,
enableWASDPanning: true,
enableRFZooming: true,
)
: const KeyboardOptions.disabled(),
),
),
children: [
TileLayer(
@@ -265,6 +338,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
),
],
),
if (isDesktop) _buildDesktopMapControls(),
Positioned(
top: 12,
right: 12,
+259 -62
View File
@@ -1,6 +1,5 @@
import 'dart:collection';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@@ -16,6 +15,7 @@ import '../connector/meshcore_protocol.dart';
import '../models/app_settings.dart';
import '../models/channel.dart';
import '../models/contact.dart';
import '../l10n/contact_localization.dart';
import '../services/app_settings_service.dart';
import '../services/path_history_service.dart';
import '../services/map_marker_service.dart';
@@ -37,6 +37,7 @@ import 'line_of_sight_map_screen.dart';
class MapScreen extends StatefulWidget {
final LatLng? highlightPosition;
final String? highlightLabel;
final String? highlightMarkerKey;
final double highlightZoom;
final bool hideBackButton;
@@ -44,6 +45,7 @@ class MapScreen extends StatefulWidget {
super.key,
this.highlightPosition,
this.highlightLabel,
this.highlightMarkerKey,
this.highlightZoom = 15.0,
this.hideBackButton = false,
});
@@ -55,6 +57,8 @@ class MapScreen extends StatefulWidget {
class _MapScreenState extends State<MapScreen> {
// Zoom level at which node labels start to appear
static const double _labelZoomThreshold = 14.0;
static const double _mapMinZoom = 2.0;
static const double _mapMaxZoom = 18.0;
final MapController _mapController = MapController();
final MapMarkerService _markerService = MapMarkerService();
@@ -94,6 +98,19 @@ class _MapScreenState extends State<MapScreen> {
_removedMarkerIds = ids;
_removedMarkersLoaded = true;
});
// If this screen was opened to highlight a marker, and that marker
// was previously removed, re-enable it now that we've loaded the saved
// removed IDs.
if (widget.highlightMarkerKey != null &&
_removedMarkerIds.contains(widget.highlightMarkerKey)) {
final updated = Set<String>.from(_removedMarkerIds);
updated.remove(widget.highlightMarkerKey);
if (!mounted) return;
setState(() {
_removedMarkerIds = updated;
});
await _markerService.saveRemovedIds(updated);
}
}
bool _checkLocationPlausibility(double lat, double lon) {
@@ -134,11 +151,62 @@ class _MapScreenState extends State<MapScreen> {
return zoom.clamp(4.0, 15.0);
}
bool _isDesktopPlatform(TargetPlatform platform) {
return platform == TargetPlatform.linux ||
platform == TargetPlatform.windows ||
platform == TargetPlatform.macOS;
}
void _zoomMapBy(double delta) {
final camera = _mapController.camera;
final nextZoom = (camera.zoom + delta)
.clamp(_mapMinZoom, _mapMaxZoom)
.toDouble();
_mapController.move(camera.center, nextZoom);
}
Widget _buildDesktopMapControls(
BuildContext context, {
required LatLng center,
required double zoom,
required bool hasPathSelector,
}) {
return Positioned(
left: 16,
top: hasPathSelector ? null : 16,
bottom: hasPathSelector ? 16 : null,
child: Card(
elevation: 4,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.add),
tooltip: 'Zoom in',
onPressed: () => _zoomMapBy(1),
),
IconButton(
icon: const Icon(Icons.remove),
tooltip: 'Zoom out',
onPressed: () => _zoomMapBy(-1),
),
IconButton(
icon: const Icon(Icons.my_location),
tooltip: 'Center map',
onPressed: () => _mapController.move(center, zoom),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Consumer3<MeshCoreConnector, AppSettingsService, PathHistoryService>(
builder: (context, connector, settingsService, pathHistory, child) {
final tileCache = context.read<MapTileCacheService>();
final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
final settings = settingsService.settings;
final allContacts = connector.allContacts;
@@ -229,6 +297,24 @@ class _MapScreenState extends State<MapScreen> {
: <Polyline>[],
);
// Collect polylines for shared markers' history with dashed lines
final List<Polyline> sharedMarkerPolylines = [];
for (final marker in sharedMarkers) {
if (marker.history.isNotEmpty) {
final points = List<LatLng>.from(marker.history);
points.add(marker.position);
sharedMarkerPolylines.add(
Polyline(
points: points,
color: marker.isChannel
? (marker.isPublicChannel ? Colors.orange : Colors.purple)
: Colors.blue,
strokeWidth: 3,
),
);
}
}
// Calculate center and zoom of all nodes, or default to (0, 0)
LatLng center = const LatLng(0, 0);
double initialZoom = 10.0;
@@ -417,10 +503,20 @@ class _MapScreenState extends State<MapScreen> {
options: MapOptions(
initialCenter: center,
initialZoom: initialZoom,
minZoom: 2.0,
maxZoom: 18.0,
minZoom: _mapMinZoom,
maxZoom: _mapMaxZoom,
interactionOptions: InteractionOptions(
flags: ~InteractiveFlag.rotate,
scrollWheelVelocity: isDesktop ? 0.012 : 0.005,
cursorKeyboardRotationOptions:
CursorKeyboardRotationOptions.disabled(),
keyboardOptions: isDesktop
? const KeyboardOptions(
enableArrowKeysPanning: true,
enableWASDPanning: true,
enableRFZooming: true,
)
: const KeyboardOptions.disabled(),
),
onTap: (_, latLng) {
if (_isSelectingPoi) {
@@ -475,6 +571,8 @@ class _MapScreenState extends State<MapScreen> {
),
if (_polylines.isNotEmpty && _isBuildingPathTrace)
PolylineLayer(polylines: _polylines),
if (sharedMarkerPolylines.isNotEmpty)
PolylineLayer(polylines: sharedMarkerPolylines),
MarkerLayer(
markers: [
if (highlightPosition != null)
@@ -562,6 +660,13 @@ class _MapScreenState extends State<MapScreen> {
sharedMarkers.length,
guessedLocations.length,
),
if (isDesktop)
_buildDesktopMapControls(
context,
center: center,
zoom: initialZoom,
hasPathSelector: _isBuildingPathTrace,
),
if (_isBuildingPathTrace) _buildPathTraceOverlay(),
],
),
@@ -1239,28 +1344,39 @@ class _MapScreenState extends State<MapScreen> {
}
List<_SharedMarker> _collectSharedMarkers(MeshCoreConnector connector) {
final markers = <_SharedMarker>[];
// Build a _SharedMarker per message (history empty), grouped by dedupe key.
// Afterwards pick the latest per key and fill its history from older ones.
final updatesByKey = <String, List<_SharedMarker>>{};
final selfName = connector.selfName ?? 'Me';
void addUpdate(_SharedMarker update) {
(updatesByKey[update.id] ??= <_SharedMarker>[]).add(update);
}
for (final contact in connector.contacts) {
final messages = connector.getMessages(contact);
for (final message in messages) {
final payload = _parseMarkerText(message.text);
final payload = parseMarkerText(message.text);
if (payload == null) continue;
final fromName = message.isOutgoing ? selfName : contact.name;
final id = _buildMarkerId(
final key = buildSharedMarkerKey(
sourceId: contact.publicKeyHex,
timestamp: message.timestamp,
text: message.text,
label: payload.label,
fromName: fromName,
flags: payload.flags,
isChannel: false,
);
markers.add(
addUpdate(
_SharedMarker(
id: id,
id: key,
position: payload.position,
label: payload.label,
label: payload.label.isEmpty
? context.l10n.map_sharedPin
: payload.label,
flags: payload.flags,
fromName: fromName,
sourceLabel: contact.name,
timestamp: message.timestamp,
isChannel: false,
isPublicChannel: false,
),
@@ -1272,23 +1388,28 @@ class _MapScreenState extends State<MapScreen> {
final isPublic = _isPublicChannel(channel);
final messages = connector.getChannelMessages(channel);
for (final message in messages) {
final payload = _parseMarkerText(message.text);
final payload = parseMarkerText(message.text);
if (payload == null) continue;
final id = _buildMarkerId(
final key = buildSharedMarkerKey(
sourceId: 'channel:${channel.index}',
timestamp: message.timestamp,
text: message.text,
label: payload.label,
fromName: message.senderName,
flags: payload.flags,
isChannel: true,
);
markers.add(
addUpdate(
_SharedMarker(
id: id,
id: key,
position: payload.position,
label: payload.label,
label: payload.label.isEmpty
? context.l10n.map_sharedPin
: payload.label,
flags: payload.flags,
fromName: message.senderName,
sourceLabel: channel.name.isEmpty
? 'Channel ${channel.index}'
: channel.name,
timestamp: message.timestamp,
isChannel: true,
isPublicChannel: isPublic,
),
@@ -1296,38 +1417,27 @@ class _MapScreenState extends State<MapScreen> {
}
}
final markers = <_SharedMarker>[];
updatesByKey.forEach((_, updates) {
updates.sort((a, b) => a.timestamp.compareTo(b.timestamp));
final latest = updates.last;
// History: older positions, drop consecutive duplicates at same position.
final history = <LatLng>[];
for (var i = 0; i < updates.length - 1; i++) {
final p = updates[i].position;
if (history.isEmpty ||
history.last.latitude != p.latitude ||
history.last.longitude != p.longitude) {
history.add(p);
}
}
markers.add(latest.copyWithHistory(history));
});
markers.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return markers;
}
_MarkerPayload? _parseMarkerText(String text) {
final trimmed = text.trim();
if (!trimmed.startsWith('m:')) return null;
final parts = trimmed.substring(2).split('|');
if (parts.isEmpty) return null;
final coords = parts[0].split(',');
if (coords.length != 2) return null;
final lat = double.tryParse(coords[0].trim());
final lon = double.tryParse(coords[1].trim());
if (lat == null || lon == null) return null;
final label = parts.length > 1 ? parts[1].trim() : '';
final flags = parts.length > 2 ? parts[2].trim() : '';
return _MarkerPayload(
position: LatLng(lat, lon),
label: label.isEmpty ? context.l10n.map_sharedPin : label,
flags: flags,
);
}
String _buildMarkerId({
required String sourceId,
required DateTime timestamp,
required String text,
}) {
return '$sourceId|${timestamp.millisecondsSinceEpoch}|$text';
}
Marker _buildSharedMarker(_SharedMarker marker) {
final markerColor = marker.isChannel
? (marker.isPublicChannel ? Colors.orange : Colors.purple)
@@ -1337,7 +1447,15 @@ class _MapScreenState extends State<MapScreen> {
width: 60,
height: 60,
child: GestureDetector(
onTap: () => _showMarkerInfo(marker),
onTap: () async {
if (_removedMarkerIds.contains(marker.id)) {
setState(() {
_removedMarkerIds.remove(marker.id);
});
await _markerService.saveRemovedIds(_removedMarkerIds);
}
_showMarkerInfo(marker);
},
child: Column(
children: [
Container(
@@ -1391,11 +1509,17 @@ class _MapScreenState extends State<MapScreen> {
room: room,
// onLogin(password, isAdmin) isAdmin not used for room caht screen
onLogin: (password, _) {
// Navigate to chat screen after successful login
context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex);
final connector = context.read<MeshCoreConnector>();
final unread = connector.getUnreadCountForContactKey(
room.publicKeyHex,
);
connector.markContactRead(room.publicKeyHex);
Navigator.push(
context,
MaterialPageRoute(builder: (context) => ChatScreen(contact: room)),
MaterialPageRoute(
builder: (context) =>
ChatScreen(contact: room, initialUnreadCount: unread),
),
);
},
),
@@ -1425,23 +1549,29 @@ class _MapScreenState extends State<MapScreen> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow('Type', contact.typeLabel),
_buildInfoRow('Path', contact.pathLabel),
_buildInfoRow(
context.l10n.map_type,
contact.typeLabel(context.l10n),
),
_buildInfoRow(
context.l10n.map_path,
contact.pathLabel(context.l10n),
),
if (contact.hasLocation)
_buildInfoRow(
'Location',
context.l10n.map_location,
'${contact.latitude!.toStringAsFixed(6)}, ${contact.longitude!.toStringAsFixed(6)}',
)
else if (guessedPosition != null)
_buildInfoRow(
'Est. Location',
context.l10n.map_estLocation,
'~${guessedPosition.latitude.toStringAsFixed(6)}, ${guessedPosition.longitude.toStringAsFixed(6)}',
),
_buildInfoRow(
context.l10n.map_lastSeen,
_formatLastSeen(contact.lastSeen),
),
_buildInfoRow('Public Key', contact.publicKeyHex),
_buildInfoRow(context.l10n.map_publicKey, contact.publicKeyHex),
],
),
actions: [
@@ -1456,11 +1586,17 @@ class _MapScreenState extends State<MapScreen> {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
}
final unread = connector.getUnreadCountForContactKey(
contact.publicKeyHex,
);
Navigator.pop(dialogContext);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChatScreen(contact: contact),
builder: (context) => ChatScreen(
contact: contact,
initialUnreadCount: unread,
),
),
);
},
@@ -1542,7 +1678,9 @@ class _MapScreenState extends State<MapScreen> {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(marker.label),
title: Text(
marker.label.isEmpty ? context.l10n.map_sharedPin : marker.label,
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1550,7 +1688,11 @@ class _MapScreenState extends State<MapScreen> {
_buildInfoRow(context.l10n.map_from, marker.fromName),
_buildInfoRow(context.l10n.map_source, marker.sourceLabel),
_buildInfoRow(
'Location',
context.l10n.map_sharedAt,
_formatLastSeen(marker.timestamp),
),
_buildInfoRow(
context.l10n.map_location,
'${marker.position.latitude.toStringAsFixed(6)}, ${marker.position.longitude.toStringAsFixed(6)}',
),
if (marker.flags.isNotEmpty)
@@ -1715,6 +1857,10 @@ class _MapScreenState extends State<MapScreen> {
String defaultLabel,
) async {
final controller = TextEditingController(text: defaultLabel);
controller.selection = TextSelection(
baseOffset: 0,
extentOffset: controller.text.length,
);
return showDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
@@ -2019,7 +2165,7 @@ class _MapScreenState extends State<MapScreen> {
enabled: settings.mapKeyPrefixEnabled,
decoration: InputDecoration(
labelText: context.l10n.map_publicKeyPrefix,
hintText: 'e.g. ab12',
hintText: context.l10n.map_publicKeyPrefixHint,
border: const OutlineInputBorder(),
isDense: true,
),
@@ -2310,18 +2456,50 @@ class _GuessedLocation {
});
}
class _MarkerPayload {
class MarkerPayload {
final LatLng position;
final String label;
final String flags;
_MarkerPayload({
MarkerPayload({
required this.position,
required this.label,
required this.flags,
});
}
/// Parse a shared marker text message of the form
/// `m:<lat>,<lon>|<label>|<flags>` and return a [MarkerPayload].
MarkerPayload? parseMarkerText(String text) {
final trimmed = text.trim();
final match = RegExp(
r'm:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|(.*)',
).firstMatch(trimmed);
if (match == null) return null;
final lat = double.tryParse(match.group(1) ?? '');
final lon = double.tryParse(match.group(2) ?? '');
if (lat == null || lon == null) return null;
final label = (match.group(3) ?? '').trim();
final flags = (match.group(4) ?? '').trim();
return MarkerPayload(position: LatLng(lat, lon), label: label, flags: flags);
}
/// Build a normalized dedupe key for shared markers.
/// Keeps the same algorithm previously present in both chat and map screens.
String buildSharedMarkerKey({
required String sourceId,
required String label,
required String fromName,
required String flags,
required bool isChannel,
}) {
final normalizedLabel = label.trim().toLowerCase();
final normalizedFrom = fromName.trim().toLowerCase();
final normalizedFlags = flags.trim().toLowerCase();
final scope = isChannel ? 'ch' : 'dm';
return '$scope|$sourceId|$normalizedFrom|$normalizedLabel|$normalizedFlags';
}
class _SharedMarker {
final String id;
final LatLng position;
@@ -2329,8 +2507,10 @@ class _SharedMarker {
final String flags;
final String fromName;
final String sourceLabel;
final DateTime timestamp;
final bool isChannel;
final bool isPublicChannel;
final List<LatLng> history;
_SharedMarker({
required this.id,
@@ -2339,7 +2519,24 @@ class _SharedMarker {
required this.flags,
required this.fromName,
required this.sourceLabel,
required this.timestamp,
required this.isChannel,
required this.isPublicChannel,
this.history = const [],
});
_SharedMarker copyWithHistory(List<LatLng> newHistory) {
return _SharedMarker(
id: id,
position: position,
label: label,
flags: flags,
fromName: fromName,
sourceLabel: sourceLabel,
timestamp: timestamp,
isChannel: isChannel,
isPublicChannel: isPublicChannel,
history: newHistory,
);
}
}
+85 -3
View File
@@ -76,9 +76,12 @@ class PathTraceMapScreen extends StatefulWidget {
class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
static const double _labelZoomThreshold = 8.5;
static const double _mapMinZoom = 2.0;
static const double _mapMaxZoom = 18.0;
//miles to meters conversion for filtering out repeaters that are too far from the last known GPS hop to be a likely match, to avoid false matches that throw off the inferred positions of other hops in the path
static const double _maxRepeaterMatchDistanceMeters = 40 * 1609.344;
final MapController _mapController = MapController();
StreamSubscription<Uint8List>? _frameSubscription;
Timer? _timeoutTimer;
@@ -116,11 +119,74 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
@override
void dispose() {
_mapController.dispose();
_frameSubscription?.cancel();
_timeoutTimer?.cancel();
super.dispose();
}
bool _isDesktopPlatform(TargetPlatform platform) {
return platform == TargetPlatform.linux ||
platform == TargetPlatform.windows ||
platform == TargetPlatform.macOS;
}
void _zoomMapBy(double delta) {
final camera = _mapController.camera;
final nextZoom = (camera.zoom + delta)
.clamp(_mapMinZoom, _mapMaxZoom)
.toDouble();
_mapController.move(camera.center, nextZoom);
}
void _resetMapView() {
final bounds = _bounds;
if (bounds != null) {
_mapController.fitCamera(
CameraFit.bounds(
bounds: bounds,
padding: const EdgeInsets.all(64),
maxZoom: 16,
),
);
return;
}
final center = _initialCenter;
if (center != null) {
_mapController.move(center, _initialZoom);
}
}
Widget _buildDesktopMapControls() {
return Positioned(
top: 16,
left: 16,
child: Card(
elevation: 4,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.add),
tooltip: 'Zoom in',
onPressed: () => _zoomMapBy(1),
),
IconButton(
icon: const Icon(Icons.remove),
tooltip: 'Zoom out',
onPressed: () => _zoomMapBy(-1),
),
IconButton(
icon: const Icon(Icons.my_location),
tooltip: 'Center map',
onPressed: _resetMapView,
),
],
),
),
);
}
Uint8List buildPath(Uint8List pathBytes) {
Uint8List traceBytes;
@@ -519,6 +585,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
),
if (_hasData)
_buildMapPathTrace(context, tileCache, _targetContact),
if (_hasData && _isDesktopPlatform(defaultTargetPlatform))
_buildDesktopMapControls(),
if (_points.isEmpty &&
!_hasData &&
!_isLoading &&
@@ -801,10 +869,24 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
MapTileCacheService tileCache,
Contact? target,
) {
final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
return FlutterMap(
key: _mapKey,
mapController: _mapController,
options: MapOptions(
interactionOptions: InteractionOptions(flags: ~InteractiveFlag.rotate),
interactionOptions: InteractionOptions(
flags: ~InteractiveFlag.rotate,
scrollWheelVelocity: isDesktop ? 0.012 : 0.005,
cursorKeyboardRotationOptions:
CursorKeyboardRotationOptions.disabled(),
keyboardOptions: isDesktop
? const KeyboardOptions(
enableArrowKeysPanning: true,
enableWASDPanning: true,
enableRFZooming: true,
)
: const KeyboardOptions.disabled(),
),
initialCenter: _initialCenter!,
initialZoom: _initialZoom,
initialCameraFit: _bounds == null
@@ -814,8 +896,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
padding: const EdgeInsets.all(64),
maxZoom: 16,
),
minZoom: 2.0,
maxZoom: 18.0,
minZoom: _mapMinZoom,
maxZoom: _mapMaxZoom,
onPositionChanged: (camera, hasGesture) {
final shouldShow = camera.zoom >= _labelZoomThreshold;
if (shouldShow != _showNodeLabels && mounted) {
+2 -1
View File
@@ -3,6 +3,7 @@ import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../l10n/contact_localization.dart';
import '../services/app_settings_service.dart';
import 'repeater_status_screen.dart';
import 'repeater_cli_screen.dart';
@@ -93,7 +94,7 @@ class RepeaterHubScreen extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
repeater.pathLabel,
repeater.pathLabel(context.l10n),
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
if (repeater.hasLocation) ...[
+11 -17
View File
@@ -353,9 +353,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.red),
title: Text("Delete All Paths"),
title: Text(l10n.settings_deleteAllPaths),
subtitle: Text(
"Clear all path data from contacts.",
l10n.settings_deleteAllPathsSubtitle,
style: TextStyle(color: Colors.red[700]),
),
onTap: () => connector.deleteAllPaths(),
@@ -1023,6 +1023,15 @@ void _privacySettings(BuildContext context, MeshCoreConnector connector) {
},
),
const SizedBox(height: 8),
SwitchListTile(
title: Text(l10n.settings_multiAck),
value: multiAcks == 1,
onChanged: (value) {
setDialogState(() => multiAcks = value ? 1 : 0);
},
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
initialValue: telemetryMode,
decoration: InputDecoration(
@@ -1064,21 +1073,6 @@ void _privacySettings(BuildContext context, MeshCoreConnector connector) {
}
},
),
const SizedBox(height: 16),
Text(
l10n.settings_multiAck(multiAcks.toString()),
style: Theme.of(context).textTheme.bodyMedium,
),
Slider(
value: multiAcks.toDouble(),
min: 0,
max: 2,
divisions: 2,
label: multiAcks.toString(),
onChanged: (value) {
setDialogState(() => multiAcks = value.round());
},
),
],
),
),