mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-11 11:07:04 +10:00
Merge main into chrome/main
This commit is contained in:
@@ -18,7 +18,9 @@ import '../l10n/l10n.dart';
|
||||
import '../models/channel.dart';
|
||||
import '../models/channel_message.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/chat_text_scale_service.dart';
|
||||
import '../utils/emoji_utils.dart';
|
||||
import '../widgets/chat_zoom_wrapper.dart';
|
||||
import '../widgets/emoji_picker.dart';
|
||||
import '../widgets/gif_message.dart';
|
||||
import '../widgets/jump_to_bottom_button.dart';
|
||||
@@ -219,37 +221,50 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
ListView.builder(
|
||||
reverse: true, // List grows from bottom up
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
// Loading indicator now appears at end (bottom) of reversed list
|
||||
if (_isLoadingOlder && index == itemCount - 1) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
ChatZoomWrapper(
|
||||
child: ListView.builder(
|
||||
reverse: true, // List grows from bottom up
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
// Loading indicator now appears at end (bottom) of reversed list
|
||||
if (_isLoadingOlder && index == itemCount - 1) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final messageIndex = index;
|
||||
final message = reversedMessages[messageIndex];
|
||||
if (!_messageKeys.containsKey(message.messageId)) {
|
||||
_messageKeys[message.messageId] = GlobalKey();
|
||||
}
|
||||
return Container(
|
||||
key: _messageKeys[message.messageId]!,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final textScale = context
|
||||
.select<ChatTextScaleService, double>(
|
||||
(service) => service.scale,
|
||||
);
|
||||
return _buildMessageBubble(
|
||||
message,
|
||||
textScale,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
final messageIndex = index;
|
||||
final message = reversedMessages[messageIndex];
|
||||
if (!_messageKeys.containsKey(message.messageId)) {
|
||||
_messageKeys[message.messageId] = GlobalKey();
|
||||
}
|
||||
return Container(
|
||||
key: _messageKeys[message.messageId]!,
|
||||
child: _buildMessageBubble(message),
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
JumpToBottomButton(scrollController: _scrollController),
|
||||
],
|
||||
@@ -264,7 +279,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageBubble(ChannelMessage message) {
|
||||
Widget _buildMessageBubble(ChannelMessage message, double textScale) {
|
||||
final settingsService = context.watch<AppSettingsService>();
|
||||
final enableTracing = settingsService.settings.enableMessageTracing;
|
||||
final isOutgoing = message.isOutgoing;
|
||||
@@ -278,6 +293,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
|
||||
const maxSwipeOffset = 64.0;
|
||||
const replySwipeThreshold = 64.0;
|
||||
const bodyFontSize = 14.0;
|
||||
final messageBody = Column(
|
||||
crossAxisAlignment: isOutgoing
|
||||
? CrossAxisAlignment.end
|
||||
@@ -334,7 +350,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
if (gifId == null) const SizedBox(height: 4),
|
||||
],
|
||||
if (message.replyToMessageId != null) ...[
|
||||
_buildReplyPreview(message),
|
||||
_buildReplyPreview(message, textScale),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (poi != null)
|
||||
@@ -342,6 +358,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
context,
|
||||
poi,
|
||||
isOutgoing,
|
||||
textScale,
|
||||
trailing: (!enableTracing && isOutgoing)
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
@@ -415,9 +432,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
Flexible(
|
||||
child: Linkify(
|
||||
text: message.text,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
linkStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
style: TextStyle(
|
||||
fontSize: bodyFontSize * textScale,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
fontSize: bodyFontSize * textScale,
|
||||
color: Colors.green,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
@@ -595,7 +614,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReplyPreview(ChannelMessage message) {
|
||||
Widget _buildReplyPreview(ChannelMessage message, double textScale) {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final isOwnNode = message.replyToSenderName == connector.selfName;
|
||||
final replyText = message.replyToText ?? '';
|
||||
@@ -623,7 +642,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
context.l10n.chat_location,
|
||||
style: TextStyle(fontSize: 12, color: previewTextColor),
|
||||
style: TextStyle(fontSize: 12 * textScale, color: previewTextColor),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -633,7 +652,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontSize: 12 * textScale,
|
||||
color: previewTextColor,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
@@ -657,7 +676,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
Text(
|
||||
context.l10n.chat_replyTo(message.replyToSenderName ?? ''),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontSize: 11 * textScale,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isOwnNode
|
||||
? Theme.of(context).colorScheme.primary
|
||||
@@ -736,7 +755,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
Widget _buildPoiMessage(
|
||||
BuildContext context,
|
||||
_PoiInfo poi,
|
||||
bool isOutgoing, {
|
||||
bool isOutgoing,
|
||||
double textScale, {
|
||||
Widget? trailing,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -774,12 +794,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.chat_poiShared,
|
||||
style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14 * textScale,
|
||||
),
|
||||
),
|
||||
if (poi.label.isNotEmpty)
|
||||
Text(
|
||||
poi.label,
|
||||
style: TextStyle(color: metaColor, fontSize: 12),
|
||||
style: TextStyle(color: metaColor, fontSize: 12 * textScale),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -849,7 +873,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
return colors[hash.abs() % colors.length];
|
||||
}
|
||||
|
||||
Widget _buildReplyBanner() {
|
||||
Widget _buildReplyBanner(double textScale) {
|
||||
final message = _replyingToMessage!;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
@@ -875,7 +899,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
Text(
|
||||
context.l10n.chat_replyingTo(message.senderName),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontSize: 12 * textScale,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
@@ -885,7 +909,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontSize: 11 * textScale,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSecondaryContainer.withValues(alpha: 0.7),
|
||||
@@ -912,7 +936,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_replyingToMessage != null) _buildReplyBanner(),
|
||||
if (_replyingToMessage != null)
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final textScale = context.select<ChatTextScaleService, double>(
|
||||
(service) => service.scale,
|
||||
);
|
||||
return _buildReplyBanner(textScale);
|
||||
},
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -489,12 +489,13 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
ChannelMessageStore channelMessageStore,
|
||||
Channel channel,
|
||||
) {
|
||||
final parentContext = context;
|
||||
final settingsService = context.read<AppSettingsService>();
|
||||
final isMuted = settingsService.isChannelMuted(channel.name);
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
context: parentContext,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -502,10 +503,10 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
leading: const Icon(Icons.edit_outlined),
|
||||
title: Text(context.l10n.channels_editChannel),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(sheetContext);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (context.mounted) {
|
||||
_showEditChannelDialog(context, connector, channel);
|
||||
if (parentContext.mounted) {
|
||||
_showEditChannelDialog(parentContext, connector, channel);
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -521,7 +522,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
: context.l10n.channels_muteChannel,
|
||||
),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(sheetContext);
|
||||
if (isMuted) {
|
||||
await settingsService.unmuteChannel(channel.name);
|
||||
} else {
|
||||
@@ -536,9 +537,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(sheetContext);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (context.mounted) {
|
||||
if (parentContext.mounted) {
|
||||
_confirmDeleteChannel(
|
||||
context,
|
||||
connector,
|
||||
@@ -1454,7 +1455,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
child: Text(dialogContext.l10n.common_cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
onPressed: () async {
|
||||
final name = nameController.text.trim();
|
||||
final pskHex = pskController.text.trim();
|
||||
|
||||
@@ -1471,13 +1472,25 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
}
|
||||
|
||||
Navigator.pop(dialogContext);
|
||||
connector.setChannel(channel.index, name, psk);
|
||||
connector.setChannelSmazEnabled(channel.index, smazEnabled);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.channels_channelUpdated(name)),
|
||||
),
|
||||
);
|
||||
try {
|
||||
await connector.setChannel(channel.index, name, psk);
|
||||
await connector.setChannelSmazEnabled(
|
||||
channel.index,
|
||||
smazEnabled,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.channels_channelUpdated(name)),
|
||||
),
|
||||
);
|
||||
} catch (e, st) {
|
||||
debugPrint(st.toString());
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to update channel: $e')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(dialogContext.l10n.common_save),
|
||||
),
|
||||
@@ -1506,17 +1519,36 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
child: Text(dialogContext.l10n.common_cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
onPressed: () async {
|
||||
Navigator.pop(dialogContext);
|
||||
connector.deleteChannel(channel.index);
|
||||
channelMessageStore.clearChannelMessages(channel.index);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.channels_channelDeleted(channel.name),
|
||||
try {
|
||||
await connector.deleteChannel(channel.index);
|
||||
|
||||
channelMessageStore.clearChannelMessages(channel.index);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.channels_channelDeleted(channel.name),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
} catch (e, st) {
|
||||
if (!context.mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.channels_channelDeleteFailed(channel.name),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Preserve existing logging (if it was there)
|
||||
debugPrint('Failed to delete channel: $e\n$st');
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
dialogContext.l10n.common_delete,
|
||||
|
||||
@@ -22,7 +22,9 @@ import '../models/contact.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/path_history.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/chat_text_scale_service.dart';
|
||||
import '../services/path_history_service.dart';
|
||||
import '../widgets/chat_zoom_wrapper.dart';
|
||||
import '../widgets/elements_ui.dart';
|
||||
import 'channel_message_path_screen.dart';
|
||||
import 'map_screen.dart';
|
||||
@@ -270,52 +272,62 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
_scrollController.scrollToBottomIfAtBottom();
|
||||
});
|
||||
|
||||
return ListView.builder(
|
||||
reverse: true, // List grows from bottom up
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
// Loading indicator now appears at end (bottom) of reversed list
|
||||
if (_isLoadingOlder && index == itemCount - 1) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
return ChatZoomWrapper(
|
||||
child: ListView.builder(
|
||||
reverse: true, // List grows from bottom up
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
// Loading indicator now appears at end (bottom) of reversed list
|
||||
if (_isLoadingOlder && index == itemCount - 1) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final messageIndex = index;
|
||||
Contact contact = widget.contact;
|
||||
final message = reversedMessages[messageIndex];
|
||||
String fourByteHex = '';
|
||||
if (widget.contact.type == advTypeRoom) {
|
||||
contact = _resolveContactFrom4Bytes(
|
||||
connector,
|
||||
message.fourByteRoomContactKey.isEmpty
|
||||
? Uint8List.fromList([0, 0, 0, 0])
|
||||
: message.fourByteRoomContactKey,
|
||||
);
|
||||
fourByteHex = message.fourByteRoomContactKey
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toUpperCase();
|
||||
}
|
||||
);
|
||||
}
|
||||
final messageIndex = index;
|
||||
Contact contact = widget.contact;
|
||||
final message = reversedMessages[messageIndex];
|
||||
String fourByteHex = '';
|
||||
if (widget.contact.type == advTypeRoom) {
|
||||
contact = _resolveContactFrom4Bytes(
|
||||
connector,
|
||||
message.fourByteRoomContactKey.isEmpty
|
||||
? Uint8List.fromList([0, 0, 0, 0])
|
||||
: message.fourByteRoomContactKey,
|
||||
);
|
||||
fourByteHex = message.fourByteRoomContactKey
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
return _MessageBubble(
|
||||
message: message,
|
||||
senderName: widget.contact.type == advTypeRoom
|
||||
? "${contact.name} [$fourByteHex]"
|
||||
: contact.name,
|
||||
isRoomServer: widget.contact.type == advTypeRoom,
|
||||
onTap: () => _openMessagePath(message, contact),
|
||||
onLongPress: () => _showMessageActions(message, contact),
|
||||
);
|
||||
},
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final textScale = context.select<ChatTextScaleService, double>(
|
||||
(service) => service.scale,
|
||||
);
|
||||
return _MessageBubble(
|
||||
message: message,
|
||||
senderName: widget.contact.type == advTypeRoom
|
||||
? "${contact.name} [$fourByteHex]"
|
||||
: contact.name,
|
||||
isRoomServer: widget.contact.type == advTypeRoom,
|
||||
textScale: textScale,
|
||||
onTap: () => _openMessagePath(message, contact),
|
||||
onLongPress: () => _showMessageActions(message, contact),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1180,11 +1192,13 @@ class _MessageBubble extends StatelessWidget {
|
||||
final bool isRoomServer;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onLongPress;
|
||||
final double textScale;
|
||||
|
||||
const _MessageBubble({
|
||||
required this.message,
|
||||
required this.senderName,
|
||||
required this.isRoomServer,
|
||||
required this.textScale,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
});
|
||||
@@ -1207,6 +1221,7 @@ class _MessageBubble extends StatelessWidget {
|
||||
? colorScheme.onErrorContainer
|
||||
: (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface);
|
||||
final metaColor = textColor.withValues(alpha: 0.7);
|
||||
const bodyFontSize = 14.0;
|
||||
String messageText = message.text;
|
||||
if (isRoomServer && !isOutgoing) {
|
||||
messageText = message.text.substring(4.clamp(0, message.text.length));
|
||||
@@ -1275,6 +1290,7 @@ class _MessageBubble extends StatelessWidget {
|
||||
poi,
|
||||
textColor,
|
||||
metaColor,
|
||||
textScale,
|
||||
trailing: (!enableTracing && isOutgoing)
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
@@ -1338,10 +1354,14 @@ class _MessageBubble extends StatelessWidget {
|
||||
Flexible(
|
||||
child: Linkify(
|
||||
text: messageText,
|
||||
style: TextStyle(color: textColor),
|
||||
linkStyle: const TextStyle(
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: bodyFontSize * textScale,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
color: Colors.green,
|
||||
decoration: TextDecoration.underline,
|
||||
fontSize: bodyFontSize * textScale,
|
||||
),
|
||||
options: const LinkifyOptions(
|
||||
humanize: false,
|
||||
@@ -1481,7 +1501,8 @@ class _MessageBubble extends StatelessWidget {
|
||||
BuildContext context,
|
||||
_PoiInfo poi,
|
||||
Color textColor,
|
||||
Color metaColor, {
|
||||
Color metaColor,
|
||||
double textScale, {
|
||||
Widget? trailing,
|
||||
}) {
|
||||
return Row(
|
||||
@@ -1510,12 +1531,16 @@ class _MessageBubble extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.chat_poiShared,
|
||||
style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14 * textScale,
|
||||
),
|
||||
),
|
||||
if (poi.label.isNotEmpty)
|
||||
Text(
|
||||
poi.label,
|
||||
style: TextStyle(color: metaColor, fontSize: 12),
|
||||
style: TextStyle(color: metaColor, fontSize: 12 * textScale),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -14,8 +14,10 @@ import '../services/app_settings_service.dart';
|
||||
import '../services/line_of_sight_service.dart';
|
||||
import '../services/map_tile_cache_service.dart';
|
||||
import '../utils/route_transitions.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../widgets/app_bar.dart';
|
||||
import '../widgets/quick_switch_bar.dart';
|
||||
import '../icons/los_icon.dart';
|
||||
|
||||
class LineOfSightEndpoint {
|
||||
final String label;
|
||||
@@ -71,6 +73,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
bool _showMarkerLabels = true;
|
||||
bool _didReceivePositionUpdate = false;
|
||||
int _losRequestNonce = 0;
|
||||
bool _initialLosScheduled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -81,7 +84,16 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
_end = widget.candidates[1];
|
||||
}
|
||||
}
|
||||
_runLos();
|
||||
_scheduleInitialRun();
|
||||
}
|
||||
|
||||
void _scheduleInitialRun() {
|
||||
if (_initialLosScheduled) return;
|
||||
_initialLosScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_runLos();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -110,10 +122,13 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
});
|
||||
|
||||
try {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final frequencyMHz = _normalizeFrequencyMHz(connector.currentFreqHz);
|
||||
final result = await _lineOfSightService.analyzePath(
|
||||
[start.point, end.point],
|
||||
startAntennaHeightMeters: startAntenna,
|
||||
endAntennaHeightMeters: endAntenna,
|
||||
frequencyMHz: frequencyMHz,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!_isRunRequestCurrent(
|
||||
@@ -424,6 +439,12 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
Widget _buildControlPanel(bool isImperial) {
|
||||
_sanitizeSelection();
|
||||
final segment = _primarySegmentResult();
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final reportedFrequencyMHz = _normalizeFrequencyMHz(
|
||||
connector.currentFreqHz,
|
||||
);
|
||||
final displayFrequencyMHz = segment?.frequencyMHz ?? reportedFrequencyMHz;
|
||||
final kFactorUsed = segment?.usedKFactor;
|
||||
final endpoints = _visibleEndpoints();
|
||||
final distanceUnit = isImperial ? 'mi' : 'km';
|
||||
final heightUnit = isImperial ? 'ft' : 'm';
|
||||
@@ -461,6 +482,9 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
terrainLabel: context.l10n.losLegendTerrain,
|
||||
losBeamLabel: context.l10n.losLegendLosBeam,
|
||||
radioHorizonLabel: context.l10n.losLegendRadioHorizon,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -474,6 +498,14 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (segment != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_LosLegend(
|
||||
terrainLabel: context.l10n.losLegendTerrain,
|
||||
losBeamLabel: context.l10n.losLegendLosBeam,
|
||||
radioHorizonLabel: context.l10n.losLegendRadioHorizon,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
segment != null
|
||||
@@ -488,6 +520,52 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (displayFrequencyMHz != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2, bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.losFrequencyLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[700],
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${displayFrequencyMHz.toStringAsFixed(3)} MHz',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[700]),
|
||||
),
|
||||
if (kFactorUsed != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'k=${kFactorUsed.toStringAsFixed(3)}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: const Icon(Icons.info_outline, size: 16),
|
||||
color: Colors.grey[600],
|
||||
tooltip: context.l10n.losFrequencyInfoTooltip,
|
||||
onPressed: () {
|
||||
_showFrequencyInfoDialog(
|
||||
context,
|
||||
displayFrequencyMHz,
|
||||
kFactorUsed,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.l10n.losElevationAttribution,
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey[700]),
|
||||
@@ -642,7 +720,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
alignment: Alignment.centerRight,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _loading ? null : _runLos,
|
||||
icon: const Icon(Icons.visibility),
|
||||
icon: const LosIcon(),
|
||||
label: Text(context.l10n.losRun),
|
||||
),
|
||||
),
|
||||
@@ -896,6 +974,40 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _showFrequencyInfoDialog(
|
||||
BuildContext context,
|
||||
double frequencyMHz,
|
||||
double kFactor,
|
||||
) {
|
||||
final baselineFreq = LineOfSightService.baselineFrequencyMHz;
|
||||
final baselineK = LineOfSightService.baselineKFactor;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(context.l10n.losFrequencyDialogTitle),
|
||||
content: Text(
|
||||
context.l10n.losFrequencyDialogDescription(
|
||||
baselineK,
|
||||
baselineFreq,
|
||||
frequencyMHz,
|
||||
kFactor,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(context.l10n.common_ok),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double? _normalizeFrequencyMHz(int? frequencyKHz) {
|
||||
if (frequencyKHz == null || frequencyKHz <= 0) return null;
|
||||
return frequencyKHz / 1000.0;
|
||||
}
|
||||
}
|
||||
|
||||
class _LosProfilePainter extends CustomPainter {
|
||||
@@ -903,12 +1015,18 @@ class _LosProfilePainter extends CustomPainter {
|
||||
final String distanceUnit;
|
||||
final String heightUnit;
|
||||
final TextStyle badgeTextStyle;
|
||||
final String terrainLabel;
|
||||
final String losBeamLabel;
|
||||
final String radioHorizonLabel;
|
||||
|
||||
const _LosProfilePainter({
|
||||
required this.samples,
|
||||
required this.distanceUnit,
|
||||
required this.heightUnit,
|
||||
required this.badgeTextStyle,
|
||||
required this.terrainLabel,
|
||||
required this.losBeamLabel,
|
||||
required this.radioHorizonLabel,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -920,44 +1038,108 @@ class _LosProfilePainter extends CustomPainter {
|
||||
if (samples.length < 2) return;
|
||||
|
||||
final minY = samples
|
||||
.map((s) => math.min(s.terrainMeters, s.lineHeightMeters))
|
||||
.map(
|
||||
(s) => math.min(
|
||||
math.min(s.terrainMeters, s.lineHeightMeters),
|
||||
s.refractedHeightMeters,
|
||||
),
|
||||
)
|
||||
.reduce(math.min);
|
||||
final maxY = samples
|
||||
.map((s) => math.max(s.terrainMeters, s.lineHeightMeters))
|
||||
.map(
|
||||
(s) => math.max(
|
||||
math.max(s.terrainMeters, s.lineHeightMeters),
|
||||
s.refractedHeightMeters,
|
||||
),
|
||||
)
|
||||
.reduce(math.max);
|
||||
final ySpan = math.max(1.0, maxY - minY);
|
||||
final maxDist = math.max(1.0, samples.last.distanceMeters);
|
||||
const horizontalPadding = 12.0;
|
||||
const verticalPadding = 12.0;
|
||||
final chartWidth = math.max(1.0, size.width - horizontalPadding * 2);
|
||||
final chartHeight = math.max(1.0, size.height - verticalPadding * 2);
|
||||
|
||||
Offset mapPoint(double x, double y) {
|
||||
final px = (x / maxDist) * size.width;
|
||||
final py = size.height - ((y - minY) / ySpan) * size.height;
|
||||
final px = horizontalPadding + (x / maxDist) * chartWidth;
|
||||
final py =
|
||||
size.height - verticalPadding - ((y - minY) / ySpan) * chartHeight;
|
||||
return Offset(px, py);
|
||||
}
|
||||
|
||||
final terrainPath = ui.Path();
|
||||
terrainPath.moveTo(0, size.height);
|
||||
for (final s in samples) {
|
||||
final p = mapPoint(s.distanceMeters, s.terrainMeters);
|
||||
final firstTerrainPoint = mapPoint(
|
||||
samples.first.distanceMeters,
|
||||
samples.first.terrainMeters,
|
||||
);
|
||||
final lastTerrainPoint = mapPoint(
|
||||
samples.last.distanceMeters,
|
||||
samples.last.terrainMeters,
|
||||
);
|
||||
|
||||
double distanceForCanvasX(double x) {
|
||||
final normalized = ((x - horizontalPadding) / chartWidth).clamp(0.0, 1.0);
|
||||
return normalized * maxDist;
|
||||
}
|
||||
|
||||
double elevationToPixel(double elevation) {
|
||||
final normalized = ((elevation - minY) / ySpan).clamp(0.0, 1.0);
|
||||
return size.height - verticalPadding - normalized * chartHeight;
|
||||
}
|
||||
|
||||
double extrapolateTerrain(double distance, bool isLeft) {
|
||||
final samplesForSlope = isLeft
|
||||
? samples.sublist(0, math.min(2, samples.length))
|
||||
: samples.sublist(samples.length - math.min(2, samples.length));
|
||||
if (samplesForSlope.length < 2) {
|
||||
return samplesForSlope.first.terrainMeters;
|
||||
}
|
||||
final a = samplesForSlope.first;
|
||||
final b = samplesForSlope.last;
|
||||
final dx = b.distanceMeters - a.distanceMeters;
|
||||
if (dx.abs() < 1e-6) return a.terrainMeters;
|
||||
final slope = (b.terrainMeters - a.terrainMeters) / dx;
|
||||
return a.terrainMeters + slope * (distance - a.distanceMeters);
|
||||
}
|
||||
|
||||
final leftDistance = distanceForCanvasX(0.0);
|
||||
final rightDistance = distanceForCanvasX(size.width);
|
||||
final leftEdgeTerrain = extrapolateTerrain(leftDistance, true);
|
||||
final rightEdgeTerrain = extrapolateTerrain(rightDistance, false);
|
||||
final leftEdgePoint = Offset(0.0, elevationToPixel(leftEdgeTerrain));
|
||||
final rightEdgePoint = Offset(
|
||||
size.width,
|
||||
elevationToPixel(rightEdgeTerrain),
|
||||
);
|
||||
|
||||
final terrainPath = ui.Path()
|
||||
..moveTo(0, size.height)
|
||||
..lineTo(leftEdgePoint.dx, leftEdgePoint.dy)
|
||||
..lineTo(firstTerrainPoint.dx, firstTerrainPoint.dy);
|
||||
for (final sample in samples) {
|
||||
final p = mapPoint(sample.distanceMeters, sample.terrainMeters);
|
||||
terrainPath.lineTo(p.dx, p.dy);
|
||||
}
|
||||
terrainPath.lineTo(size.width, size.height);
|
||||
terrainPath.close();
|
||||
terrainPath
|
||||
..lineTo(lastTerrainPoint.dx, lastTerrainPoint.dy)
|
||||
..lineTo(rightEdgePoint.dx, rightEdgePoint.dy)
|
||||
..lineTo(size.width, size.height)
|
||||
..close();
|
||||
|
||||
canvas.drawPath(terrainPath, Paint()..color = const Color(0xCC7C6F5D));
|
||||
const terrainFillColor = Color(0xCC7C6F5D);
|
||||
const terrainLineColor = Color(0xFF9FE870);
|
||||
const losLineColor = Color(0xFFE0E7FF);
|
||||
canvas.drawPath(terrainPath, Paint()..color = terrainFillColor);
|
||||
|
||||
final terrainLine = ui.Path();
|
||||
for (int i = 0; i < samples.length; i++) {
|
||||
final p = mapPoint(samples[i].distanceMeters, samples[i].terrainMeters);
|
||||
if (i == 0) {
|
||||
terrainLine.moveTo(p.dx, p.dy);
|
||||
} else {
|
||||
terrainLine.lineTo(p.dx, p.dy);
|
||||
}
|
||||
final terrainLine = ui.Path()..moveTo(leftEdgePoint.dx, leftEdgePoint.dy);
|
||||
for (final sample in samples) {
|
||||
final p = mapPoint(sample.distanceMeters, sample.terrainMeters);
|
||||
terrainLine.lineTo(p.dx, p.dy);
|
||||
}
|
||||
terrainLine.lineTo(rightEdgePoint.dx, rightEdgePoint.dy);
|
||||
canvas.drawPath(
|
||||
terrainLine,
|
||||
Paint()
|
||||
..color = const Color(0xFF9FE870)
|
||||
..color = terrainLineColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2,
|
||||
);
|
||||
@@ -977,10 +1159,59 @@ class _LosProfilePainter extends CustomPainter {
|
||||
canvas.drawPath(
|
||||
losLine,
|
||||
Paint()
|
||||
..color = const Color(0xFFE0E7FF)
|
||||
..color = losLineColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2,
|
||||
);
|
||||
|
||||
const refractedLineColor = Color(0xFFFFD57F);
|
||||
final refractedLine = ui.Path();
|
||||
for (int i = 0; i < samples.length; i++) {
|
||||
final p = mapPoint(
|
||||
samples[i].distanceMeters,
|
||||
samples[i].refractedHeightMeters,
|
||||
);
|
||||
if (i == 0) {
|
||||
refractedLine.moveTo(p.dx, p.dy);
|
||||
} else {
|
||||
refractedLine.lineTo(p.dx, p.dy);
|
||||
}
|
||||
}
|
||||
canvas.drawPath(
|
||||
refractedLine,
|
||||
Paint()
|
||||
..color = refractedLineColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5,
|
||||
);
|
||||
|
||||
final capPath = ui.Path();
|
||||
for (int i = 0; i < samples.length; i++) {
|
||||
final p = mapPoint(
|
||||
samples[i].distanceMeters,
|
||||
samples[i].refractedHeightMeters,
|
||||
);
|
||||
if (i == 0) {
|
||||
capPath.moveTo(p.dx, p.dy);
|
||||
} else {
|
||||
capPath.lineTo(p.dx, p.dy);
|
||||
}
|
||||
}
|
||||
for (int i = samples.length - 1; i >= 0; i--) {
|
||||
final p = mapPoint(
|
||||
samples[i].distanceMeters,
|
||||
samples[i].lineHeightMeters,
|
||||
);
|
||||
capPath.lineTo(p.dx, p.dy);
|
||||
}
|
||||
capPath.close();
|
||||
const horizonFillColor = Color(0x40FFD57F);
|
||||
canvas.drawPath(
|
||||
capPath,
|
||||
Paint()
|
||||
..color = horizonFillColor
|
||||
..style = PaintingStyle.fill,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -988,7 +1219,10 @@ class _LosProfilePainter extends CustomPainter {
|
||||
return oldDelegate.samples != samples ||
|
||||
oldDelegate.distanceUnit != distanceUnit ||
|
||||
oldDelegate.heightUnit != heightUnit ||
|
||||
oldDelegate.badgeTextStyle != badgeTextStyle;
|
||||
oldDelegate.badgeTextStyle != badgeTextStyle ||
|
||||
oldDelegate.terrainLabel != terrainLabel ||
|
||||
oldDelegate.losBeamLabel != losBeamLabel ||
|
||||
oldDelegate.radioHorizonLabel != radioHorizonLabel;
|
||||
}
|
||||
|
||||
void _drawUnitBadge(Canvas canvas, Size size) {
|
||||
@@ -1001,3 +1235,73 @@ class _LosProfilePainter extends CustomPainter {
|
||||
painter.paint(canvas, Offset(size.width - painter.width - 8, 8));
|
||||
}
|
||||
}
|
||||
|
||||
class _LosLegend extends StatelessWidget {
|
||||
static const _terrainColor = Color(0xFF9FE870);
|
||||
static const _losColor = Color(0xFFE0E7FF);
|
||||
static const _radioColor = Color(0xFFFFD57F);
|
||||
|
||||
final String terrainLabel;
|
||||
final String losBeamLabel;
|
||||
final String radioHorizonLabel;
|
||||
|
||||
const _LosLegend({
|
||||
required this.terrainLabel,
|
||||
required this.losBeamLabel,
|
||||
required this.radioHorizonLabel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textStyle =
|
||||
Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.white70,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
) ??
|
||||
const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
|
||||
final entries = [
|
||||
_LegendEntry(terrainLabel, _terrainColor),
|
||||
_LegendEntry(losBeamLabel, _losColor),
|
||||
_LegendEntry(radioHorizonLabel, _radioColor),
|
||||
];
|
||||
|
||||
const swatchSize = 10.0;
|
||||
|
||||
return Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 6,
|
||||
children: entries
|
||||
.map(
|
||||
(entry) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: swatchSize,
|
||||
height: swatchSize,
|
||||
decoration: BoxDecoration(
|
||||
color: entry.color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(entry.label, style: textStyle),
|
||||
],
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LegendEntry {
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
const _LegendEntry(this.label, this.color);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import '../services/map_tile_cache_service.dart';
|
||||
import '../utils/contact_search.dart';
|
||||
import '../utils/route_transitions.dart';
|
||||
import '../widgets/quick_switch_bar.dart';
|
||||
import '../icons/los_icon.dart';
|
||||
import 'channels_screen.dart';
|
||||
import 'chat_screen.dart';
|
||||
import 'contacts_screen.dart';
|
||||
@@ -280,7 +281,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
),
|
||||
if (!_isBuildingPathTrace)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.visibility),
|
||||
icon: const LosIcon(),
|
||||
onPressed: () {
|
||||
final candidates = <LineOfSightEndpoint>[];
|
||||
if (connector.selfLatitude != null &&
|
||||
|
||||
Reference in New Issue
Block a user