mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-24 19:32:52 +10:00
Merge branch 'dev' of https://github.com/zjs81/meshcore-open into dev
This commit is contained in:
@@ -1240,15 +1240,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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -33,13 +33,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();
|
||||
@@ -56,32 +62,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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -103,6 +123,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();
|
||||
@@ -124,6 +151,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_connector?.setActiveChannel(null);
|
||||
_scrollController.showJumpToBottom.removeListener(_clearDividerAtBottom);
|
||||
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
|
||||
_textFieldFocusNode.dispose();
|
||||
_textController.dispose();
|
||||
@@ -322,6 +350,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(
|
||||
@@ -330,10 +361,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;
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -353,12 +391,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
|
||||
@@ -446,6 +496,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
poi,
|
||||
isOutgoing,
|
||||
textScale,
|
||||
message.senderName,
|
||||
trailing: (!enableTracing && isOutgoing)
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
@@ -556,7 +607,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],
|
||||
@@ -577,7 +630,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],
|
||||
@@ -702,7 +755,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) {
|
||||
@@ -813,24 +866,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;
|
||||
@@ -850,12 +891,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,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1240,14 +1291,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1303,6 +1361,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),
|
||||
@@ -1522,11 +1589,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});
|
||||
}
|
||||
|
||||
@@ -492,13 +492,18 @@ 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1552,7 +1557,7 @@ 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')),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
+108
-52
@@ -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';
|
||||
@@ -21,6 +20,7 @@ import '../helpers/gif_helper.dart';
|
||||
import '../helpers/path_helper.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';
|
||||
@@ -45,12 +45,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();
|
||||
@@ -64,6 +70,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
bool _isLoadingOlder = false;
|
||||
MeshCoreConnector? _connector;
|
||||
Message? _pendingUnreadScrollTarget;
|
||||
String? _unreadDividerMessageId;
|
||||
DateTime? _lastTextSendAt;
|
||||
|
||||
@override
|
||||
@@ -71,34 +78,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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -117,6 +137,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();
|
||||
@@ -138,6 +165,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_connector?.setActiveContact(null);
|
||||
_scrollController.showJumpToBottom.removeListener(_clearDividerAtBottom);
|
||||
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
|
||||
_textFieldFocusNode.dispose();
|
||||
_textController.dispose();
|
||||
@@ -480,6 +508,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),
|
||||
@@ -487,10 +516,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;
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -498,6 +536,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;
|
||||
@@ -1184,8 +1234,8 @@ 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),
|
||||
@@ -1409,11 +1459,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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1550,6 +1604,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),
|
||||
@@ -1657,10 +1720,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,
|
||||
@@ -1675,7 +1740,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
|
||||
@@ -1767,6 +1832,7 @@ class _MessageBubble extends StatelessWidget {
|
||||
textColor,
|
||||
metaColor,
|
||||
textScale,
|
||||
senderName,
|
||||
trailing: (!enableTracing && isOutgoing)
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
@@ -1948,25 +2014,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(
|
||||
@@ -1976,13 +2030,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,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -2163,11 +2227,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});
|
||||
}
|
||||
|
||||
@@ -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,18 @@ 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 +997,10 @@ 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,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
password: password,
|
||||
isAdmin: isAdmin,
|
||||
)
|
||||
: ChatScreen(contact: room),
|
||||
: ChatScreen(
|
||||
contact: room,
|
||||
initialUnreadCount: unread,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -1122,7 +1137,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 +1481,7 @@ class _ContactTile extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
contact.pathLabel,
|
||||
contact.pathLabel(context.l10n),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
@@ -62,6 +62,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
LineOfSightPathResult? _result;
|
||||
LineOfSightObstruction? _selectedObstruction;
|
||||
LineOfSightEndpoint? _start;
|
||||
LineOfSightEndpoint? _end;
|
||||
final List<LineOfSightEndpoint> _customEndpoints = [];
|
||||
@@ -111,6 +112,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
if (start == null || end == null) {
|
||||
setState(() {
|
||||
_result = null;
|
||||
_selectedObstruction = null;
|
||||
_error = _errorSelectStartEnd;
|
||||
});
|
||||
return;
|
||||
@@ -142,6 +144,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
}
|
||||
setState(() {
|
||||
_result = result;
|
||||
_selectedObstruction = _defaultObstructionFor(result);
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
@@ -156,6 +159,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
}
|
||||
setState(() {
|
||||
_result = null;
|
||||
_selectedObstruction = null;
|
||||
_error = context.l10n.losRunFailed(e.toString());
|
||||
});
|
||||
} finally {
|
||||
@@ -184,6 +188,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 +246,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
_start = null;
|
||||
_end = null;
|
||||
_result = null;
|
||||
_selectedObstruction = null;
|
||||
_error = _errorSelectStartEnd;
|
||||
});
|
||||
}
|
||||
@@ -251,6 +257,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
if (identical(_start, endpoint)) _start = null;
|
||||
if (identical(_end, endpoint)) _end = null;
|
||||
_result = null;
|
||||
_selectedObstruction = null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -377,7 +384,9 @@ 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 (_showHud)
|
||||
@@ -445,6 +454,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 +474,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 +506,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 +682,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
_showDisplayNodes = value;
|
||||
_sanitizeSelection();
|
||||
_result = null;
|
||||
_selectedObstruction = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
@@ -655,6 +733,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
setState(() {
|
||||
_start = value;
|
||||
_result = null;
|
||||
_selectedObstruction = null;
|
||||
});
|
||||
if (_start != null && _end != null) {
|
||||
_runLos();
|
||||
@@ -670,6 +749,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
setState(() {
|
||||
_end = value;
|
||||
_result = null;
|
||||
_selectedObstruction = null;
|
||||
});
|
||||
if (_start != null && _end != null) {
|
||||
_runLos();
|
||||
@@ -769,6 +849,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 +1073,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 +1306,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 +1359,7 @@ class _LosProfilePainter extends CustomPainter {
|
||||
final String terrainLabel;
|
||||
final String losBeamLabel;
|
||||
final String radioHorizonLabel;
|
||||
final int? selectedSampleIndex;
|
||||
|
||||
const _LosProfilePainter({
|
||||
required this.samples,
|
||||
@@ -1027,6 +1369,7 @@ class _LosProfilePainter extends CustomPainter {
|
||||
required this.terrainLabel,
|
||||
required this.losBeamLabel,
|
||||
required this.radioHorizonLabel,
|
||||
this.selectedSampleIndex,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -1212,6 +1555,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 +1591,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) {
|
||||
|
||||
+181
-59
@@ -16,6 +16,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 +38,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 +46,7 @@ class MapScreen extends StatefulWidget {
|
||||
super.key,
|
||||
this.highlightPosition,
|
||||
this.highlightLabel,
|
||||
this.highlightMarkerKey,
|
||||
this.highlightZoom = 15.0,
|
||||
this.hideBackButton = false,
|
||||
});
|
||||
@@ -94,6 +97,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) {
|
||||
@@ -229,6 +245,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;
|
||||
@@ -475,6 +509,8 @@ class _MapScreenState extends State<MapScreen> {
|
||||
),
|
||||
if (_polylines.isNotEmpty && _isBuildingPathTrace)
|
||||
PolylineLayer(polylines: _polylines),
|
||||
if (sharedMarkerPolylines.isNotEmpty)
|
||||
PolylineLayer(polylines: sharedMarkerPolylines),
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
if (highlightPosition != null)
|
||||
@@ -1239,28 +1275,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 +1319,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 +1348,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 +1378,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 +1440,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 +1480,23 @@ 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 +1511,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 +1603,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 +1613,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 +1782,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 +2090,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 +2381,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 +2432,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 +2444,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) ...[
|
||||
|
||||
@@ -341,9 +341,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(),
|
||||
|
||||
Reference in New Issue
Block a user