feat: add contact UI helpers and path editor for routing management

- Implemented contactTypeIcon and contactTypeColor functions for better UI representation of contact types.
- Created colorForName and firstCharacterOrEmoji functions to enhance contact display.
- Developed PathEditorSheet widget for managing contact paths with a user-friendly interface.
- Introduced RoutingSheet for managing contact routing modes and displaying path history.
- Added a script for generating proof of concept (PoC) payloads for clipboard contact import validation.
This commit is contained in:
zjs81
2026-06-11 00:07:12 -07:00
parent 743ef7f124
commit cba1e5950c
86 changed files with 8149 additions and 6379 deletions
+11 -10
View File
@@ -63,7 +63,7 @@ class AppDebugLogScreen extends StatelessWidget {
final entry = entries[index];
return ListTile(
dense: true,
leading: _buildLevelIcon(entry.level),
leading: _buildLevelIcon(context, entry.level),
title: Text(
'[${entry.tag}] ${entry.message}',
style: const TextStyle(
@@ -75,7 +75,7 @@ class AppDebugLogScreen extends StatelessWidget {
entry.formattedTime,
style: TextStyle(
fontSize: 10,
color: Colors.grey[600],
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
@@ -88,14 +88,14 @@ class AppDebugLogScreen extends StatelessWidget {
Icon(
Icons.bug_report_outlined,
size: 64,
color: Colors.grey[400],
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
context.l10n.debugLog_noEntries,
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
@@ -103,7 +103,7 @@ class AppDebugLogScreen extends StatelessWidget {
context.l10n.debugLog_enableInSettings,
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
@@ -115,18 +115,19 @@ class AppDebugLogScreen extends StatelessWidget {
);
}
Widget _buildLevelIcon(AppDebugLogLevel level) {
Widget _buildLevelIcon(BuildContext context, AppDebugLogLevel level) {
final colorScheme = Theme.of(context).colorScheme;
switch (level) {
case AppDebugLogLevel.info:
return const Icon(Icons.info_outline, size: 18, color: Colors.blue);
return Icon(Icons.info_outline, size: 18, color: colorScheme.primary);
case AppDebugLogLevel.warning:
return const Icon(
return Icon(
Icons.warning_amber_outlined,
size: 18,
color: Colors.orange,
color: colorScheme.tertiary,
);
case AppDebugLogLevel.error:
return const Icon(Icons.error_outline, size: 18, color: Colors.red);
return Icon(Icons.error_outline, size: 18, color: colorScheme.error);
}
}
}
+62 -91
View File
@@ -92,11 +92,29 @@ class AppSettingsScreen extends StatelessWidget {
ListTile(
leading: const Icon(Icons.brightness_6_outlined),
title: Text(context.l10n.appSettings_theme),
subtitle: Text(
_themeModeLabel(context, settingsService.settings.themeMode),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8),
child: SegmentedButton<String>(
segments: [
ButtonSegment(
value: 'system',
label: Text(context.l10n.appSettings_themeSystem),
),
ButtonSegment(
value: 'light',
label: Text(context.l10n.appSettings_themeLight),
),
ButtonSegment(
value: 'dark',
label: Text(context.l10n.appSettings_themeDark),
),
],
selected: {settingsService.settings.themeMode},
onSelectionChanged: (selection) {
settingsService.setThemeMode(selection.first);
},
),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showThemeModeDialog(context, settingsService),
),
const Divider(height: 1),
ListTile(
@@ -111,18 +129,6 @@ class AppSettingsScreen extends StatelessWidget {
trailing: const Icon(Icons.chevron_right),
onTap: () => _showLanguageDialog(context, settingsService),
),
const Divider(height: 1),
SwitchListTile(
secondary: const Icon(Icons.location_searching),
title: Text(context.l10n.appSettings_enableMessageTracing),
subtitle: Text(
context.l10n.appSettings_enableMessageTracingSubtitle,
),
value: settingsService.settings.enableMessageTracing,
onChanged: (value) {
settingsService.setEnableMessageTracing(value);
},
),
],
),
);
@@ -189,14 +195,14 @@ class AppSettingsScreen extends StatelessWidget {
Icons.message_outlined,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
: Theme.of(context).disabledColor,
),
title: Text(
context.l10n.appSettings_messageNotifications,
style: TextStyle(
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
: Theme.of(context).disabledColor,
),
),
subtitle: Text(
@@ -204,7 +210,7 @@ class AppSettingsScreen extends StatelessWidget {
style: TextStyle(
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
: Theme.of(context).disabledColor,
),
),
value: settingsService.settings.notifyOnNewMessage,
@@ -220,14 +226,14 @@ class AppSettingsScreen extends StatelessWidget {
Icons.forum_outlined,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
: Theme.of(context).disabledColor,
),
title: Text(
context.l10n.appSettings_channelMessageNotifications,
style: TextStyle(
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
: Theme.of(context).disabledColor,
),
),
subtitle: Text(
@@ -235,7 +241,7 @@ class AppSettingsScreen extends StatelessWidget {
style: TextStyle(
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
: Theme.of(context).disabledColor,
),
),
value: settingsService.settings.notifyOnNewChannelMessage,
@@ -251,14 +257,14 @@ class AppSettingsScreen extends StatelessWidget {
Icons.cell_tower,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
: Theme.of(context).disabledColor,
),
title: Text(
context.l10n.appSettings_advertisementNotifications,
style: TextStyle(
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
: Theme.of(context).disabledColor,
),
),
subtitle: Text(
@@ -266,7 +272,7 @@ class AppSettingsScreen extends StatelessWidget {
style: TextStyle(
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
: Theme.of(context).disabledColor,
),
),
value: settingsService.settings.notifyOnNewAdvert,
@@ -343,10 +349,16 @@ class AppSettingsScreen extends StatelessWidget {
);
},
),
if (settingsService.settings.autoRouteRotationEnabled) ...[
const Divider(height: 1),
ListTile(
title: Text(context.l10n.appSettings_maxRouteWeight),
if (settingsService.settings.autoRouteRotationEnabled)
Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
padding: const EdgeInsets.only(left: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(height: 1),
ListTile(
title: Text(context.l10n.appSettings_maxRouteWeight),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -454,7 +466,21 @@ class AppSettingsScreen extends StatelessWidget {
],
),
),
],
],
),
),
const Divider(height: 1),
SwitchListTile(
secondary: const Icon(Icons.location_searching),
title: Text(context.l10n.appSettings_enableMessageTracing),
subtitle: Text(
context.l10n.appSettings_enableMessageTracingSubtitle,
),
value: settingsService.settings.enableMessageTracing,
onChanged: (value) {
settingsService.setEnableMessageTracing(value);
},
),
],
),
);
@@ -584,15 +610,15 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile(
secondary: Icon(
Icons.auto_awesome_outlined,
color: translationEnabled ? null : Colors.grey,
color: translationEnabled ? null : Theme.of(context).disabledColor,
),
title: Text(
context.l10n.translation_autoIncomingTitle,
style: TextStyle(color: translationEnabled ? null : Colors.grey),
style: TextStyle(color: translationEnabled ? null : Theme.of(context).disabledColor),
),
subtitle: Text(
context.l10n.translation_autoIncomingSubtitle,
style: TextStyle(color: translationEnabled ? null : Colors.grey),
style: TextStyle(color: translationEnabled ? null : Theme.of(context).disabledColor),
),
value: settings.autoTranslateIncomingMessages,
onChanged: translationEnabled
@@ -603,15 +629,15 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile(
secondary: Icon(
Icons.outgoing_mail,
color: translationEnabled ? null : Colors.grey,
color: translationEnabled ? null : Theme.of(context).disabledColor,
),
title: Text(
context.l10n.translation_composerTitle,
style: TextStyle(color: translationEnabled ? null : Colors.grey),
style: TextStyle(color: translationEnabled ? null : Theme.of(context).disabledColor),
),
subtitle: Text(
context.l10n.translation_composerSubtitle,
style: TextStyle(color: translationEnabled ? null : Colors.grey),
style: TextStyle(color: translationEnabled ? null : Theme.of(context).disabledColor),
),
value: settings.composerTranslationEnabled,
onChanged: translationEnabled
@@ -871,61 +897,6 @@ class AppSettingsScreen extends StatelessWidget {
);
}
void _showThemeModeDialog(
BuildContext context,
AppSettingsService settingsService,
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.appSettings_theme),
content: RadioGroup<String>(
groupValue: settingsService.settings.themeMode,
onChanged: (value) {
if (value != null) {
settingsService.setThemeMode(value);
Navigator.pop(context);
}
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<String>(
title: Text(context.l10n.appSettings_themeSystem),
value: 'system',
),
RadioListTile<String>(
title: Text(context.l10n.appSettings_themeLight),
value: 'light',
),
RadioListTile<String>(
title: Text(context.l10n.appSettings_themeDark),
value: 'dark',
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.common_close),
),
],
),
);
}
String _themeModeLabel(BuildContext context, String value) {
switch (value) {
case 'light':
return context.l10n.appSettings_themeLight;
case 'dark':
return context.l10n.appSettings_themeDark;
default:
return context.l10n.appSettings_themeSystem;
}
}
String _languageLabel(BuildContext context, String? languageCode) {
switch (languageCode) {
case 'en':
+209 -238
View File
@@ -5,7 +5,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:intl/intl.dart' hide TextDirection;
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
@@ -25,8 +25,9 @@ import '../models/translation_support.dart';
import '../services/app_settings_service.dart';
import '../services/chat_text_scale_service.dart';
import '../services/translation_service.dart';
import '../utils/emoji_utils.dart';
import '../helpers/contact_ui.dart';
import '../widgets/byte_count_input.dart';
import '../widgets/empty_state.dart';
import '../widgets/chat_zoom_wrapper.dart';
import '../widgets/emoji_picker.dart';
import '../widgets/gif_message.dart';
@@ -283,6 +284,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
)
: widget.channel.name,
style: const TextStyle(fontSize: 16),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
@@ -311,9 +314,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
icon: const Icon(Icons.more_vert),
onSelected: (value) {
if (value == 'clearChat') {
context.read<MeshCoreConnector>().clearMessagesForChannel(
widget.channel.index,
);
_confirmClearChat();
}
},
itemBuilder: (context) => [
@@ -321,11 +322,17 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
value: 'clearChat',
child: Row(
children: [
const Icon(Icons.delete, size: 20, color: Colors.red),
Icon(
Icons.delete,
size: 20,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 12),
Text(
context.l10n.contact_clearChat,
style: const TextStyle(color: Colors.red),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
],
),
@@ -344,34 +351,17 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final messages = connector.getChannelMessages(widget.channel);
if (messages.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
widget.channel.isPublicChannel
? Icons.public
: Icons.tag,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
context.l10n.chat_noMessages,
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
context.l10n.chat_sendMessageToStart,
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
),
),
],
return EmptyState(
icon: widget.channel.isPublicChannel
? Icons.public
: Icons.tag,
title: context.l10n.chat_noMessages,
subtitle: context.l10n.chat_sendMessageTo(
widget.channel.name.isEmpty
? context.l10n.channels_channelIndex(
widget.channel.index,
)
: widget.channel.name,
),
);
}
@@ -381,6 +371,25 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final itemCount =
reversedMessages.length + (_isLoadingOlder ? 1 : 0);
// Prune stale keys (deleted/cleared messages) to avoid
// unbounded growth.
final liveIds = reversedMessages
.map((m) => m.messageId)
.toSet();
_messageKeys.removeWhere((id, _) => !liveIds.contains(id));
// Two messages can collide on messageId (same ms + name/text
// hash). Only the first occurrence owns the shared GlobalKey
// used for scroll-to-message; duplicates get a local key so
// no two widgets share one GlobalKey.
final seenIds = <String>{};
final keyedIndices = <int>{};
for (var i = 0; i < reversedMessages.length; i++) {
if (seenIds.add(reversedMessages[i].messageId)) {
keyedIndices.add(i);
}
}
// Auto-scroll to bottom if user is already at bottom
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_channelSkipNextBottomSnap) {
@@ -416,14 +425,20 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}
final messageIndex = index;
final message = reversedMessages[messageIndex];
if (!_messageKeys.containsKey(message.messageId)) {
_messageKeys[message.messageId] = GlobalKey();
final GlobalKey messageKey;
if (keyedIndices.contains(messageIndex)) {
messageKey = _messageKeys.putIfAbsent(
message.messageId,
GlobalKey.new,
);
} else {
messageKey = GlobalKey();
}
final isUnreadAnchor =
_unreadDividerMessageId != null &&
message.messageId == _unreadDividerMessageId;
return Container(
key: _messageKeys[message.messageId]!,
key: messageKey,
child: Builder(
builder: (context) {
final textScale = context
@@ -495,7 +510,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
const maxSwipeOffset = 64.0;
const replySwipeThreshold = 64.0;
const bodyFontSize = 14.0;
final messageBody = Column(
final messageBody = LayoutBuilder(
builder: (context, constraints) => Column(
crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
@@ -512,9 +528,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
],
Flexible(
child: GestureDetector(
onTap: PlatformInfo.isDesktop
? null
: () => _showMessagePathInfo(message),
onLongPress: () => _showMessageActions(message),
onSecondaryTapUp: PlatformInfo.isDesktop
? (_) => _showMessageActions(message)
@@ -524,7 +537,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
? const EdgeInsets.all(4)
: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65,
maxWidth: constraints.maxWidth * 0.65,
),
decoration: BoxDecoration(
color: isOutgoing
@@ -566,20 +579,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
isOutgoing,
textScale,
message.senderName,
trailing: (!enableTracing && isOutgoing)
? Padding(
padding: const EdgeInsets.only(bottom: 2),
child: MessageStatusIcon(
isAcked:
message.status ==
ChannelMessageStatus.sent &&
displayPath.isNotEmpty,
isFailed:
message.status ==
ChannelMessageStatus.failed,
),
)
: null,
)
else if (gifId != null)
Stack(
@@ -599,36 +598,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
.withValues(alpha: 0.6),
),
),
if (!enableTracing && isOutgoing)
Positioned(
top: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: isOutgoing
? Theme.of(
context,
).colorScheme.primaryContainer
: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(10),
topRight: Radius.circular(8),
),
),
child: MessageStatusIcon(
isAcked:
message.status ==
ChannelMessageStatus.sent &&
displayPath.isNotEmpty,
isFailed:
message.status ==
ChannelMessageStatus.failed,
),
),
),
],
)
else
@@ -651,97 +620,89 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
),
),
if (!enableTracing && isOutgoing) ...[
const SizedBox(width: 4),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: MessageStatusIcon(
isAcked:
message.status ==
ChannelMessageStatus.sent &&
displayPath.isNotEmpty,
isFailed:
message.status ==
ChannelMessageStatus.failed,
],
),
if (enableTracing && displayPath.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
context.l10n.channels_via(
_formatPathPrefixes(displayPath),
),
style: TextStyle(
fontSize: 11 * textScale,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
),
],
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.only(
left: 8,
right: 8,
bottom: 4,
)
: EdgeInsets.zero,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(context, message.timestamp),
style: TextStyle(
fontSize: 11 * textScale,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
if (enableTracing && message.repeatCount > 0) ...[
const SizedBox(width: 6),
Icon(
Icons.repeat,
size: 12 * textScale,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 2),
Text(
'${message.repeatCount}',
style: TextStyle(
fontSize: 11 * textScale,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
if (isOutgoing) ...[
const SizedBox(width: 4),
MessageStatusIcon(
isAcked:
message.status ==
ChannelMessageStatus.sent,
isRepeated:
message.status ==
ChannelMessageStatus.sent &&
displayPath.isNotEmpty,
isPending:
message.status ==
ChannelMessageStatus.pending,
isFailed:
message.status ==
ChannelMessageStatus.failed,
),
],
],
),
if (enableTracing) ...[
if (displayPath.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
context.l10n.channels_via(
_formatPathPrefixes(displayPath),
),
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
),
],
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.only(
left: 8,
right: 8,
bottom: 4,
)
: EdgeInsets.zero,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(context, message.timestamp),
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
if (message.repeatCount > 0) ...[
const SizedBox(width: 6),
Icon(
Icons.repeat,
size: 12,
color: Colors.grey[600],
),
const SizedBox(width: 2),
Text(
'${message.repeatCount}',
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
],
if (isOutgoing) ...[
const SizedBox(width: 4),
Icon(
message.status == ChannelMessageStatus.sent
? Icons.check
: message.status ==
ChannelMessageStatus.pending
? Icons.schedule
: Icons.error_outline,
size: 14,
color:
message.status ==
ChannelMessageStatus.failed
? Colors.red
: Colors.grey[600],
),
],
],
),
),
],
),
],
),
),
@@ -757,6 +718,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
],
],
),
);
if (!isOutgoing && !PlatformInfo.isDesktop) {
@@ -958,9 +920,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
IconButton(
icon: Icon(Icons.location_on_outlined, color: channelColor),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
onPressed: () {
final selfName = context.read<MeshCoreConnector>().selfName ?? 'Me';
final selfName = context.read<MeshCoreConnector>().selfName ?? context.l10n.chat_me;
final fromName = isOutgoing ? selfName : senderName;
final key = buildSharedMarkerKey(
sourceId: 'channel:${widget.channel.index}',
@@ -1020,8 +982,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}
Widget _buildAvatar(String senderName) {
final initial = _getFirstCharacterOrEmoji(senderName);
final color = _getColorForName(senderName);
final initial = firstCharacterOrEmoji(senderName);
final color = colorForName(senderName);
return CircleAvatar(
radius: 18,
@@ -1037,36 +999,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
}
String _getFirstCharacterOrEmoji(String name) {
if (name.isEmpty) return '?';
final emoji = firstEmoji(name);
if (emoji != null) return emoji;
final runes = name.runes.toList();
if (runes.isEmpty) return '?';
return String.fromCharCode(runes[0]).toUpperCase();
}
Color _getColorForName(String name) {
// Generate a consistent color based on the name hash
final hash = name.hashCode;
final colors = [
Colors.blue,
Colors.green,
Colors.orange,
Colors.purple,
Colors.pink,
Colors.teal,
Colors.indigo,
Colors.cyan,
Colors.amber,
Colors.deepOrange,
];
return colors[hash.abs() % colors.length];
}
Widget _buildReplyBanner(double textScale) {
final message = _replyingToMessage!;
return Container(
@@ -1116,8 +1048,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
icon: const Icon(Icons.close, size: 18),
onPressed: _cancelReply,
color: Theme.of(context).colorScheme.onSecondaryContainer,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
],
),
@@ -1412,15 +1343,14 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_setReplyingTo(message);
},
),
if (PlatformInfo.isDesktop)
ListTile(
leading: const Icon(Icons.route),
title: Text(context.l10n.chat_path),
onTap: () {
Navigator.pop(sheetContext);
_showMessagePathInfo(message);
},
),
ListTile(
leading: const Icon(Icons.route),
title: Text(context.l10n.chat_path),
onTap: () {
Navigator.pop(sheetContext);
_showMessagePathInfo(message);
},
),
// Can't react to your own messages
if (!message.isOutgoing)
ListTile(
@@ -1463,19 +1393,21 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_markAsUnread(message);
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.delete_outline),
title: Text(context.l10n.common_delete),
leading: Icon(
Icons.delete_outline,
color: Theme.of(context).colorScheme.error,
),
title: Text(
context.l10n.common_delete,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
onTap: () async {
Navigator.pop(sheetContext);
await _deleteMessage(message);
},
),
ListTile(
leading: const Icon(Icons.close),
title: Text(context.l10n.common_cancel),
onTap: () => Navigator.pop(sheetContext),
),
],
),
),
@@ -1516,6 +1448,34 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
}
Future<void> _confirmClearChat() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(context.l10n.contact_clearChat),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: Text(context.l10n.common_delete),
),
],
),
);
if (confirmed == true) {
if (!mounted) return;
context.read<MeshCoreConnector>().clearMessagesForChannel(
widget.channel.index,
);
}
}
Future<void> _deleteMessage(ChannelMessage message) async {
await context.read<MeshCoreConnector>().deleteChannelMessage(message);
if (!mounted) return;
@@ -1557,6 +1517,7 @@ class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> {
double _maxSwipeDistance = 0;
int? _swipePointerId;
bool _swipeLockedToHorizontal = false;
bool _isRtl = false;
void _handleSwipeStart(Offset position) {
_swipeStartPosition = position;
@@ -1577,11 +1538,13 @@ class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> {
return;
}
final dx = event.position.dx - _swipeStartPosition!.dx;
final rawDx = event.position.dx - _swipeStartPosition!.dx;
// In LTR swipe left (rawDx < 0) triggers reply; in RTL swipe right (rawDx > 0).
final signedDx = _isRtl ? rawDx : -rawDx;
const axisLockThreshold = 12.0;
if (!_swipeLockedToHorizontal) {
if (-dx < axisLockThreshold) {
if (signedDx < axisLockThreshold) {
return;
}
_swipeLockedToHorizontal = true;
@@ -1593,28 +1556,32 @@ class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> {
void _handleSwipeUpdate(Offset position) {
if (_swipeStartPosition == null) return;
final dx = position.dx - _swipeStartPosition!.dx;
if (dx >= 0) return;
final rawDx = position.dx - _swipeStartPosition!.dx;
final signedDx = _isRtl ? rawDx : -rawDx;
if (signedDx <= 0) return;
if (-dx < 6) return;
if (signedDx < 6) return;
if (-dx > _maxSwipeDistance) {
_maxSwipeDistance = -dx;
if (signedDx > _maxSwipeDistance) {
_maxSwipeDistance = signedDx;
}
final double clamped = dx.clamp(-widget.maxSwipeOffset, 0.0).toDouble();
final double clamped = signedDx.clamp(0.0, widget.maxSwipeOffset);
final adjusted = _applySwipeResistance(clamped, widget.maxSwipeOffset);
if (adjusted != _swipeOffset) {
setState(() => _swipeOffset = adjusted);
// Translate in the gesture direction: negative for LTR (left), positive for RTL (right).
final translationOffset = _isRtl ? adjusted : -adjusted;
if (translationOffset != _swipeOffset) {
setState(() => _swipeOffset = translationOffset);
}
}
void _handleSwipePointerUp(Offset position) {
if (_swipeLockedToHorizontal && _swipeStartPosition != null) {
final dx = position.dx - _swipeStartPosition!.dx;
final rawDx = position.dx - _swipeStartPosition!.dx;
final signedDx = _isRtl ? rawDx : -rawDx;
final peak = math.max(
_maxSwipeDistance,
(-dx).clamp(0.0, double.infinity),
signedDx.clamp(0.0, double.infinity),
);
if (peak >= widget.replySwipeThreshold) {
widget.onReplyTriggered();
@@ -1654,6 +1621,10 @@ class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> {
@override
Widget build(BuildContext context) {
_isRtl = Directionality.of(context) == TextDirection.rtl;
// In LTR, the bubble slides left and the hint appears on the right (isStart: false).
// In RTL, the bubble slides right and the hint appears on the left (isStart: true).
final hintIsStart = _isRtl;
return Listener(
onPointerDown: _handleSwipePointerDown,
onPointerMove: _handleSwipePointerMove,
@@ -1667,7 +1638,7 @@ class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> {
Positioned.fill(
child: Opacity(
opacity: _swipeOffset.abs() / widget.maxSwipeOffset,
child: widget.hintBuilder(isStart: false),
child: widget.hintBuilder(isStart: hintIsStart),
),
),
AnimatedContainer(
+85 -55
View File
@@ -106,7 +106,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
if (!hasHopDetails)
Text(
l10n.channelPath_noHopDetails,
style: const TextStyle(color: Colors.grey),
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
)
else
..._buildHopTiles(context, hops),
@@ -131,22 +131,25 @@ class ChannelMessagePathScreen extends StatelessWidget {
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
_buildDetailRow(l10n.channelPath_senderLabel, message.senderName),
_buildDetailRow(context, l10n.channelPath_senderLabel, message.senderName),
_buildDetailRow(
context,
l10n.channelPath_timeLabel,
_formatTime(message.timestamp, l10n),
),
if (message.repeatCount > 0)
_buildDetailRow(
context,
l10n.channelPath_repeatsLabel,
message.repeatCount.toString(),
),
_buildDetailRow(
context,
l10n.channelPath_pathLabelTitle,
_formatPathLabel(message.pathLength, l10n),
),
if (observedLabel != null)
_buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
_buildDetailRow(context, l10n.channelPath_observedLabel, observedLabel),
],
),
),
@@ -250,7 +253,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
return l10n.channelPath_observedSomeOf(observedCount, pathLength);
}
Widget _buildDetailRow(String label, String value) {
Widget _buildDetailRow(BuildContext context, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
@@ -258,7 +261,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
children: [
SizedBox(
width: 70,
child: Text(label, style: TextStyle(color: Colors.grey[600])),
child: Text(label, style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)),
),
Expanded(child: Text(value)),
],
@@ -412,17 +415,17 @@ class _ChannelMessagePathMapScreenState
children: [
IconButton(
icon: const Icon(Icons.add),
tooltip: 'Zoom in',
tooltip: context.l10n.map_zoomIn,
onPressed: () => _zoomMapBy(1),
),
IconButton(
icon: const Icon(Icons.remove),
tooltip: 'Zoom out',
tooltip: context.l10n.map_zoomOut,
onPressed: () => _zoomMapBy(-1),
),
IconButton(
icon: const Icon(Icons.my_location),
tooltip: 'Center map',
tooltip: context.l10n.map_centerMap,
onPressed: () => _resetMapView(
initialCenter: initialCenter,
initialZoom: initialZoom,
@@ -593,7 +596,7 @@ class _ChannelMessagePathMapScreenState
if (points.isEmpty)
Center(
child: Card(
color: Colors.white.withValues(alpha: 0.9),
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9),
child: Padding(
padding: EdgeInsets.all(12),
child: Text(
@@ -664,7 +667,7 @@ class _ChannelMessagePathMapScreenState
label,
_formatPathPrefixes(selectedPath.pathBytes),
),
style: TextStyle(color: Colors.grey[700], fontSize: 12),
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
),
],
),
@@ -685,28 +688,32 @@ class _ChannelMessagePathMapScreenState
markers.add(
Marker(
point: point,
width: 35,
height: 35,
child: Container(
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
width: 48,
height: 48,
child: Center(
child: Container(
width: 35,
height: 35,
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: Text(
hop.index.toString(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
],
),
alignment: Alignment.center,
child: Text(
hop.index.toString(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
@@ -729,28 +736,32 @@ class _ChannelMessagePathMapScreenState
markers.add(
Marker(
point: selfPoint,
width: 35,
height: 35,
child: Container(
decoration: BoxDecoration(
color: Colors.teal,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
width: 48,
height: 48,
child: Center(
child: Container(
width: 35,
height: 35,
decoration: BoxDecoration(
color: Colors.teal,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: Text(
context.l10n.pathTrace_you,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
],
),
alignment: Alignment.center,
child: Text(
context.l10n.pathTrace_you,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
@@ -804,6 +815,12 @@ class _ChannelMessagePathMapScreenState
);
}
Widget _colorDot(Color color) => Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
);
Widget _buildLegendCard(
BuildContext context,
List<_PathHop> hops,
@@ -826,9 +843,22 @@ class _ChannelMessagePathMapScreenState
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Text(
'${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistance, isImperial: isImperial)}',
style: const TextStyle(fontWeight: FontWeight.w600),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistance, isImperial: isImperial)}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 6),
Row(
children: [
_colorDot(Colors.green),
const SizedBox(width: 4),
Text(l10n.pathTrace_legendGpsConfirmed, style: const TextStyle(fontSize: 11)),
],
),
],
),
),
const Divider(height: 1),
+113 -70
View File
@@ -111,24 +111,26 @@ class _ChannelsScreenState extends State<ChannelsScreen>
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.logout, color: Colors.red),
Icon(
Icons.logout,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8),
Text(context.l10n.common_disconnect),
],
),
onTap: () => _disconnect(context),
),
if (_communities.isNotEmpty)
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.groups),
const SizedBox(width: 8),
Text(context.l10n.community_manageCommunities),
],
),
onTap: () => _showManageCommunitiesDialog(context),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.groups),
const SizedBox(width: 8),
Text(context.l10n.community_manageCommunities),
],
),
onTap: () => _showManageCommunitiesDialog(context),
),
PopupMenuItem(
child: Row(
children: [
@@ -241,32 +243,22 @@ class _ChannelsScreenState extends State<ChannelsScreen>
),
Expanded(
child: filteredChannels.isEmpty
? ListView(
children: [
SizedBox(
height: MediaQuery.of(context).size.height - 300,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
? LayoutBuilder(
builder: (context, constraints) => ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: EmptyState(
icon: Icons.search_off,
title:
context.l10n.channels_noChannelsFound,
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
],
),
),
),
],
],
),
)
: (viewState.channelsSortOption ==
ChannelSortOption.manual &&
@@ -357,6 +349,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
int? dragIndex,
}) {
final unreadCount = connector.getUnreadCountForChannel(channel);
final isMuted = context.watch<AppSettingsService>().isChannelMuted(
channel.name,
);
// Determine icon and colors based on channel type
IconData icon;
@@ -449,37 +444,45 @@ class _ChannelsScreenState extends State<ChannelsScreen>
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isMuted) ...[
Icon(
Icons.notifications_off,
size: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
],
if (unreadCount > 0) ...[
UnreadBadge(count: unreadCount),
const SizedBox(width: 4),
],
if (showDragHandle && dragIndex != null)
ReorderableDelayedDragStartListener(
ReorderableDragStartListener(
index: dragIndex,
child: Icon(
Icons.drag_handle,
color: Theme.of(context).colorScheme.onSurfaceVariant,
child: Padding(
padding: const EdgeInsets.all(12),
child: Icon(
Icons.drag_handle,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
],
),
onTap: () async {
onTap: () {
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,
initialUnreadCount: unread,
),
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChannelChatScreen(
channel: channel,
initialUnreadCount: unread,
),
);
}
),
);
},
onLongPress: () => _showChannelActions(
context,
@@ -540,10 +543,15 @@ class _ChannelsScreenState extends State<ChannelsScreen>
},
),
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.red),
leading: Icon(
Icons.delete_outline,
color: Theme.of(sheetContext).colorScheme.error,
),
title: Text(
context.l10n.channels_deleteChannel,
style: const TextStyle(color: Colors.red),
style: TextStyle(
color: Theme.of(sheetContext).colorScheme.error,
),
),
onTap: () async {
Navigator.pop(sheetContext);
@@ -725,23 +733,39 @@ class _ChannelsScreenState extends State<ChannelsScreen>
? (isSelected
? Theme.of(dialogContext).colorScheme.primaryContainer
: null)
: Colors.grey.withValues(alpha: 0.2),
: Theme.of(
dialogContext,
).colorScheme.onSurface.withValues(alpha: 0.12),
child: Icon(
icon,
color: enabled
? (isSelected
? Theme.of(dialogContext).colorScheme.primary
: null)
: Colors.grey,
: Theme.of(
dialogContext,
).colorScheme.onSurface.withValues(alpha: 0.38),
),
),
title: Text(
title,
style: TextStyle(color: enabled ? null : Colors.grey),
style: TextStyle(
color: enabled
? null
: Theme.of(
dialogContext,
).colorScheme.onSurface.withValues(alpha: 0.38),
),
),
subtitle: Text(
subtitle,
style: TextStyle(color: enabled ? null : Colors.grey),
style: TextStyle(
color: enabled
? null
: Theme.of(
dialogContext,
).colorScheme.onSurface.withValues(alpha: 0.38),
),
),
trailing: enabled ? const Icon(Icons.chevron_right) : null,
selected: isSelected,
@@ -929,7 +953,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
Channel.publicChannelPsk,
);
Navigator.pop(dialogContext);
connector.setChannel(nextIndex, 'Public', psk);
connector.setChannel(nextIndex, context.l10n.channels_public, psk);
if (context.mounted) {
showDismissibleSnackBar(
context,
@@ -1043,7 +1067,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
dialogContext.l10n.community_hashtagPrivacyHint,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
color: Theme.of(
dialogContext,
).colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
@@ -1214,6 +1240,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
child: FilledButton(
onPressed: () async {
final name = nameController.text.trim();
final publicLabel = context.l10n.channels_public;
if (name.isEmpty) {
showDismissibleSnackBar(
context,
@@ -1238,7 +1265,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
final psk = community
.deriveCommunityPublicPsk();
final channelName =
'${community.name} Public';
'${community.name} $publicLabel';
connector.setChannel(
nextIndex,
channelName,
@@ -1594,7 +1621,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
},
child: Text(
dialogContext.l10n.common_delete,
style: const TextStyle(color: Colors.red),
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
@@ -1604,7 +1631,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
void _addPublicChannel(BuildContext context, MeshCoreConnector connector) {
final psk = Channel.parsePskHex(Channel.publicChannelPsk);
connector.setChannel(0, 'Public', psk);
connector.setChannel(0, context.l10n.channels_public, psk);
showDismissibleSnackBar(
context,
content: Text(context.l10n.channels_publicChannelAdded),
@@ -1653,14 +1680,19 @@ class _ChannelsScreenState extends State<ChannelsScreen>
Icon(
Icons.groups_outlined,
size: 64,
color: Colors.grey[400],
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.6),
),
const SizedBox(height: 16),
Text(
context.l10n.community_noCommunities,
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
@@ -1668,7 +1700,10 @@ class _ChannelsScreenState extends State<ChannelsScreen>
context.l10n.community_scanOrCreate,
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.8),
),
textAlign: TextAlign.center,
),
@@ -1692,10 +1727,12 @@ class _ChannelsScreenState extends State<ChannelsScreen>
),
title: Text(community.name),
subtitle: Text(
'ID: ${community.shortCommunityId}...',
context.l10n.channels_communityShortId(community.shortCommunityId),
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
trailing: PopupMenuButton<String>(
@@ -1722,14 +1759,20 @@ class _ChannelsScreenState extends State<ChannelsScreen>
value: 'leave',
child: Row(
children: [
const Icon(
Icon(
Icons.exit_to_app,
color: Colors.red,
color: Theme.of(
context,
).colorScheme.error,
),
const SizedBox(width: 12),
Text(
context.l10n.community_delete,
style: const TextStyle(color: Colors.red),
style: TextStyle(
color: Theme.of(
context,
).colorScheme.error,
),
),
],
),
@@ -1830,7 +1873,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
},
child: Text(
dialogContext.l10n.community_delete,
style: const TextStyle(color: Colors.red),
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
File diff suppressed because it is too large Load Diff
+12 -21
View File
@@ -8,34 +8,25 @@ class ChromeRequiredScreen extends StatelessWidget {
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final colorScheme = theme.colorScheme;
return Scaffold(
body: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 32),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDark
? [const Color(0xFF1A1A1A), const Color(0xFF0D0D0D)]
: [const Color(0xFFF5F7FA), const Color(0xFFE4E7EB)],
),
),
color: colorScheme.surface,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
color: colorScheme.tertiaryContainer.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(
child: Icon(
Icons.browser_not_supported_rounded,
size: 80,
color: Colors.orange,
color: colorScheme.tertiary,
),
),
const SizedBox(height: 32),
@@ -44,7 +35,7 @@ class ChromeRequiredScreen extends StatelessWidget {
textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : Colors.black87,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 16),
@@ -52,7 +43,7 @@ class ChromeRequiredScreen extends StatelessWidget {
l10n.scanner_chromeRequiredMessage,
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
color: isDark ? Colors.white70 : Colors.black54,
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
@@ -62,19 +53,19 @@ class ChromeRequiredScreen extends StatelessWidget {
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
color: colorScheme.secondaryContainer.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(30),
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
border: Border.all(color: colorScheme.outline.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.info_outline, size: 20, color: Colors.blue),
Icon(Icons.info_outline, size: 20, color: colorScheme.secondary),
const SizedBox(width: 12),
Text(
"Web Bluetooth requires a Chromium browser",
l10n.chrome_bluetoothRequiresChromium,
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.blue,
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w500,
),
),
@@ -210,10 +210,10 @@ class _NoiseChartPainter extends CustomPainter {
}
final span = maxV - minV;
for (var i = 0; i <= 2; i++) {
final v = maxV - span * i / 2;
for (var i = 0; i <= 4; i++) {
final v = maxV - span * i / 4;
final tp = _yAxisLabel(v);
final y = chart.top + (chart.height * i / 2) - tp.height / 2;
final y = chart.top + (chart.height * i / 4) - tp.height / 2;
tp.paint(canvas, Offset(4, y));
}
+235 -179
View File
@@ -29,6 +29,7 @@ import '../widgets/repeater_login_dialog.dart';
import '../widgets/room_login_dialog.dart';
import '../widgets/sync_progress_overlay.dart';
import '../widgets/unread_badge.dart';
import '../helpers/contact_ui.dart';
import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart';
import 'chat_screen.dart';
@@ -59,7 +60,7 @@ class _ContactsScreenState extends State<ContactsScreen>
String _loadedGroupScopeKeyHex = '';
Timer? _searchDebounce;
final Set<ContactOperationType> _pendingOperations = {};
final List<ContactOperationType> _pendingOperations = [];
StreamSubscription<Uint8List>? _frameSubscription;
@@ -185,59 +186,52 @@ class _ContactsScreenState extends State<ContactsScreen>
Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
}
// Generic OK/ERR acks carry no command correlation, so consume only
// the oldest pending operation per ack instead of clearing all.
if (code == respCodeOk) {
// Show a snackbar indicating success
if (!mounted) return;
if (_pendingOperations.contains(ContactOperationType.import)) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactImported),
);
if (_pendingOperations.isEmpty) return;
final op = _pendingOperations.removeAt(0);
switch (op) {
case ContactOperationType.import:
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactImported),
);
case ContactOperationType.zeroHopShare:
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_zeroHopContactAdvertSent),
);
case ContactOperationType.export:
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactAdvertCopied),
);
}
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_zeroHopContactAdvertSent),
);
}
if (_pendingOperations.contains(ContactOperationType.export)) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactAdvertCopied),
);
}
_pendingOperations.clear();
}
if (code == respCodeErr) {
// Show a snackbar indicating failure
if (!mounted) return;
if (_pendingOperations.contains(ContactOperationType.import)) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactImportFailed),
);
if (_pendingOperations.isEmpty) return;
final op = _pendingOperations.removeAt(0);
switch (op) {
case ContactOperationType.import:
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactImportFailed),
);
case ContactOperationType.zeroHopShare:
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_zeroHopContactAdvertFailed),
);
case ContactOperationType.export:
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactAdvertCopyFailed),
);
}
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_zeroHopContactAdvertFailed),
);
}
if (_pendingOperations.contains(ContactOperationType.export)) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactAdvertCopyFailed),
);
}
_pendingOperations.clear();
}
} catch (e) {
appLogger.error(
@@ -252,17 +246,37 @@ class _ContactsScreenState extends State<ContactsScreen>
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final exportContactFrame = buildExportContactFrame(pubKey);
_pendingOperations.add(ContactOperationType.export);
await connector.sendFrame(exportContactFrame, expectsGenericAck: true);
try {
await connector.sendFrame(exportContactFrame, expectsGenericAck: true);
} catch (e) {
_pendingOperations.remove(ContactOperationType.export);
if (mounted) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactAdvertCopyFailed),
);
}
}
}
Future<void> _contactZeroHop(Uint8List pubKey) async {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final exportContactZeroHopFrame = buildZeroHopContact(pubKey);
_pendingOperations.add(ContactOperationType.zeroHopShare);
await connector.sendFrame(
exportContactZeroHopFrame,
expectsGenericAck: true,
);
try {
await connector.sendFrame(
exportContactZeroHopFrame,
expectsGenericAck: true,
);
} catch (e) {
_pendingOperations.remove(ContactOperationType.zeroHopShare);
if (mounted) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_zeroHopContactAdvertFailed),
);
}
}
}
Future<void> _contactImport() async {
@@ -288,11 +302,10 @@ class _ContactsScreenState extends State<ContactsScreen>
return;
}
final hexString = text.substring('meshcore://'.length);
final Uint8List importContactFrame;
try {
final bytes = hex2Uint8List(hexString);
final importContactFrame = buildImportContactFrame(bytes);
_pendingOperations.add(ContactOperationType.import);
connector.importContact(importContactFrame);
importContactFrame = buildImportContactFrame(bytes);
} catch (e) {
if (mounted) {
showDismissibleSnackBar(
@@ -300,6 +313,19 @@ class _ContactsScreenState extends State<ContactsScreen>
content: Text(context.l10n.contacts_invalidAdvertFormat),
);
}
return;
}
_pendingOperations.add(ContactOperationType.import);
try {
await connector.sendFrame(importContactFrame, expectsGenericAck: true);
} catch (e) {
_pendingOperations.remove(ContactOperationType.import);
if (mounted) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactImportFailed),
);
}
}
}
@@ -322,7 +348,34 @@ class _ContactsScreenState extends State<ContactsScreen>
bottom: const SyncProgressAppBarBottom(),
actions: [
PopupMenuButton(
itemBuilder: (context) => [
tooltip: context.l10n.contacts_moreOptions,
itemBuilder: (context) => <PopupMenuEntry<dynamic>>[
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.person_add_rounded),
const SizedBox(width: 8),
Text(context.l10n.discoveredContacts_Title),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DiscoveryScreen(),
),
),
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.paste),
const SizedBox(width: 8),
Text(context.l10n.contacts_addContactFromClipboard),
],
),
onTap: () => _contactImport(),
),
const PopupMenuDivider(),
PopupMenuItem(
child: Row(
children: [
@@ -365,46 +418,20 @@ class _ContactsScreenState extends State<ContactsScreen>
),
onTap: () => _contactExport(Uint8List.fromList([])),
),
const PopupMenuDivider(),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.paste),
const SizedBox(width: 8),
Text(context.l10n.contacts_addContactFromClipboard),
],
),
onTap: () => _contactImport(),
),
],
icon: const Icon(Icons.connect_without_contact),
),
PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.logout, color: Colors.red),
Icon(
Icons.logout,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8),
Text(context.l10n.common_disconnect),
],
),
onTap: () => _disconnect(context, connector),
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.person_add_rounded),
const SizedBox(width: 8),
Text(context.l10n.discoveredContacts_Title),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DiscoveryScreen(),
),
),
),
PopupMenuItem(
child: Row(
children: [
@@ -426,6 +453,10 @@ class _ContactsScreenState extends State<ContactsScreen>
],
),
body: _buildContactsBody(context, connector),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddContactSheet(context),
child: const Icon(Icons.person_add),
),
bottomNavigationBar: SafeArea(
top: false,
child: QuickSwitchBar(
@@ -440,6 +471,40 @@ class _ContactsScreenState extends State<ContactsScreen>
);
}
void _showAddContactSheet(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.paste),
title: Text(context.l10n.contacts_addContactFromClipboard),
onTap: () {
Navigator.pop(sheetContext);
_contactImport();
},
),
ListTile(
leading: const Icon(Icons.person_add_rounded),
title: Text(context.l10n.discoveredContacts_Title),
onTap: () {
Navigator.pop(sheetContext);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DiscoveryScreen(),
),
);
},
),
],
),
),
);
}
Future<void> _disconnect(
BuildContext context,
MeshCoreConnector connector,
@@ -571,7 +636,11 @@ class _ContactsScreenState extends State<ContactsScreen>
const SizedBox(width: 8),
IconButton(
tooltip: menuContext.l10n.contacts_deleteGroup,
icon: const Icon(Icons.delete, size: 20, color: Colors.red),
icon: Icon(
Icons.delete,
size: 20,
color: Theme.of(context).colorScheme.error,
),
onPressed: canManageGroups
? () => _closeDropdownAndRun(
menuContext,
@@ -589,16 +658,25 @@ class _ContactsScreenState extends State<ContactsScreen>
],
child: SizedBox(
height: 48,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
Expanded(
child: Text(selectedGroupName, overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 8),
const Icon(Icons.arrow_drop_down),
],
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).colorScheme.outline),
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
Expanded(
child: Text(
selectedGroupName,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
const Icon(Icons.arrow_drop_down),
],
),
),
),
),
@@ -624,6 +702,16 @@ class _ContactsScreenState extends State<ContactsScreen>
icon: Icons.people_outline,
title: context.l10n.contacts_noContacts,
subtitle: context.l10n.contacts_contactsWillAppear,
action: FilledButton.icon(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DiscoveryScreen(),
),
),
icon: const Icon(Icons.person_add_rounded),
label: Text(context.l10n.discoveredContacts_Title),
),
);
}
@@ -759,6 +847,9 @@ class _ContactsScreenState extends State<ContactsScreen>
width: 48,
height: 48,
child: IconButton(
tooltip: viewState.contactsSearchExpanded
? context.l10n.contacts_searchClose
: context.l10n.contacts_searchOpen,
onPressed: () {
if (viewState.contactsSearchExpanded) {
_collapseContactsSearch(viewState);
@@ -791,25 +882,29 @@ class _ContactsScreenState extends State<ContactsScreen>
),
),
Expanded(
child: filteredAndSorted.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
viewState.contactsShowUnreadOnly
? context.l10n.contacts_noUnreadContacts
: context.l10n.contacts_noContactsFound,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
),
)
: RefreshIndicator(
onRefresh: () => connector.getContacts(),
child: ListView.builder(
child: RefreshIndicator(
onRefresh: () => connector.getContacts(),
child: filteredAndSorted.isEmpty
? LayoutBuilder(
builder: (context, constraints) => ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: EmptyState(
icon: Icons.search_off,
title: viewState.contactsShowUnreadOnly
? context.l10n.contacts_noUnreadContacts
: context.l10n.contacts_noContactsFound,
),
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.only(bottom: 88),
itemCount: filteredAndSorted.length,
itemBuilder: (context, index) {
final contact = filteredAndSorted[index];
@@ -827,7 +922,7 @@ class _ContactsScreenState extends State<ContactsScreen>
);
},
),
),
),
),
],
);
@@ -1048,7 +1143,7 @@ class _ContactsScreenState extends State<ContactsScreen>
},
child: Text(
context.l10n.common_delete,
style: const TextStyle(color: Colors.red),
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
@@ -1359,14 +1454,6 @@ class _ContactsScreenState extends State<ContactsScreen>
);
},
),
ListTile(
leading: const Icon(Icons.chat),
title: Text(context.l10n.contacts_openChat),
onTap: () {
Navigator.pop(sheetContext);
_openChat(context, contact);
},
),
],
ListTile(
leading: Icon(
@@ -1403,10 +1490,15 @@ class _ContactsScreenState extends State<ContactsScreen>
},
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
leading: Icon(
Icons.delete,
color: Theme.of(context).colorScheme.error,
),
title: Text(
context.l10n.contacts_deleteContact,
style: const TextStyle(color: Colors.red),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
onTap: () {
Navigator.pop(sheetContext);
@@ -1441,7 +1533,7 @@ class _ContactsScreenState extends State<ContactsScreen>
},
child: Text(
context.l10n.common_delete,
style: const TextStyle(color: Colors.red),
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
@@ -1473,25 +1565,14 @@ class _ContactTile extends StatelessWidget {
onSecondaryTapUp: PlatformInfo.isDesktop ? (_) => onLongPress() : null,
child: ListTile(
leading: CircleAvatar(
backgroundColor: _getTypeColor(contact.type),
backgroundColor: contactTypeColor(contact.type),
child: _buildContactAvatar(contact),
),
title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
contact.pathLabel(context.l10n),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
contact.shortPubKeyHex,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
),
],
subtitle: Text(
contact.pathLabel(context.l10n),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Clamp text scaling in trailing section to prevent overflow while
// maintaining accessibility. Primary content (title/subtitle) scales normally.
@@ -1502,7 +1583,7 @@ class _ContactTile extends StatelessWidget {
),
),
child: SizedBox(
width: 120,
width: 96,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
@@ -1516,7 +1597,10 @@ class _ContactTile extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
Row(
mainAxisSize: MainAxisSize.min,
@@ -1529,7 +1613,9 @@ class _ContactTile extends StatelessWidget {
Icon(
Icons.location_on,
size: 14,
color: Colors.grey[400],
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
],
),
@@ -1548,37 +1634,7 @@ class _ContactTile extends StatelessWidget {
if (emoji != null) {
return Text(emoji, style: const TextStyle(fontSize: 18));
}
return Icon(_getTypeIcon(contact.type), color: Colors.white, size: 20);
}
IconData _getTypeIcon(int type) {
switch (type) {
case advTypeChat:
return Icons.chat;
case advTypeRepeater:
return Icons.cell_tower;
case advTypeRoom:
return Icons.group;
case advTypeSensor:
return Icons.sensors;
default:
return Icons.device_unknown;
}
}
Color _getTypeColor(int type) {
switch (type) {
case advTypeChat:
return Colors.blue;
case advTypeRepeater:
return Colors.orange;
case advTypeRoom:
return Colors.purple;
case advTypeSensor:
return Colors.green;
default:
return Colors.grey;
}
return Icon(contactTypeIcon(contact.type), color: Colors.white, size: 20);
}
String _formatLastSeen(BuildContext context, DateTime lastSeen) {
+29 -43
View File
@@ -12,6 +12,7 @@ import '../utils/contact_search.dart';
import '../utils/platform_info.dart';
import '../widgets/app_bar.dart';
import '../widgets/list_filter_widget.dart';
import '../helpers/contact_ui.dart';
import '../helpers/snack_bar_builder.dart';
enum DiscoverySortOption { lastSeen, name, type }
@@ -71,7 +72,10 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.delete, color: Colors.red),
Icon(
Icons.delete,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8),
Text(context.l10n.discoveredContacts_deleteContactAll),
],
@@ -99,9 +103,9 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
final contact = filteredAndSorted[index];
final tile = ListTile(
leading: CircleAvatar(
backgroundColor: _getTypeColor(contact.type),
backgroundColor: contactTypeColor(contact.type),
child: Icon(
_getTypeIcon(contact.type),
contactTypeIcon(contact.type),
color: Colors.white,
size: 20,
),
@@ -142,7 +146,9 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
Row(
@@ -152,7 +158,10 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
Icon(
Icons.location_on,
size: 14,
color: Colors.grey[400],
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.6),
),
if (contact.rawPacket != null)
const SizedBox(width: 2),
@@ -160,7 +169,10 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
Icon(
Icons.cell_tower,
size: 14,
color: Colors.grey[400],
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.6),
),
],
),
@@ -170,6 +182,17 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
),
onTap: () {
connector.importDiscoveredContact(contact);
showDismissibleSnackBar(
context,
content: Text(
context.l10n.discoveredContacts_contactAdded,
),
action: SnackBarAction(
label: context.l10n.common_undo,
onPressed: () =>
connector.removeContact(contact),
),
);
},
onLongPress: () =>
_showContactContextMenu(contact, connector),
@@ -203,11 +226,6 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.add_reaction_sharp),
title: Text(l10n.discoveredContacts_addContact),
onTap: () => Navigator.of(sheetContext).pop('import_contact'),
),
ListTile(
leading: const Icon(Icons.copy),
title: Text(l10n.discoveredContacts_copyContact),
@@ -227,9 +245,6 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
if (!mounted || action == null) return;
switch (action) {
case 'import_contact':
connector.importDiscoveredContact(contact);
break;
case 'copy_contact':
if (contact.rawPacket == null) return;
final hexString = pubKeyToHex(contact.rawPacket!);
@@ -429,35 +444,6 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
}
}
IconData _getTypeIcon(int type) {
switch (type) {
case advTypeChat:
return Icons.chat;
case advTypeRepeater:
return Icons.cell_tower;
case advTypeRoom:
return Icons.group;
case advTypeSensor:
return Icons.sensors;
default:
return Icons.device_unknown;
}
}
Color _getTypeColor(int type) {
switch (type) {
case advTypeChat:
return Colors.blue;
case advTypeRepeater:
return Colors.orange;
case advTypeRoom:
return Colors.purple;
case advTypeSensor:
return Colors.green;
default:
return Colors.grey;
}
}
String _formatLastSeen(BuildContext context, DateTime lastSeen) {
final now = DateTime.now();
+24 -16
View File
@@ -73,7 +73,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
double _startAntennaHeight = 5.0;
double _endAntennaHeight = 5.0;
bool _showHud = true;
bool _menuExpanded = true;
bool _menuExpanded = false;
bool _showDisplayNodes = true;
bool _showMarkerLabels = true;
bool _didReceivePositionUpdate = false;
@@ -159,17 +159,17 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
children: [
IconButton(
icon: const Icon(Icons.add),
tooltip: 'Zoom in',
tooltip: context.l10n.map_zoomIn,
onPressed: () => _zoomMapBy(1),
),
IconButton(
icon: const Icon(Icons.remove),
tooltip: 'Zoom out',
tooltip: context.l10n.map_zoomOut,
onPressed: () => _zoomMapBy(-1),
),
IconButton(
icon: const Icon(Icons.my_location),
tooltip: 'Center map',
tooltip: context.l10n.map_centerMap,
onPressed: () => _resetMapView(
initialCenter: initialCenter,
initialZoom: initialZoom,
@@ -224,6 +224,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
setState(() {
_result = result;
_selectedObstruction = _defaultObstructionFor(result);
_menuExpanded = true;
});
} catch (e) {
if (!mounted) return;
@@ -506,7 +507,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
bottom: 12,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black54,
color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(8),
),
child: Padding(
@@ -516,11 +517,18 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
),
child: Text(
context.l10n.losElevationAttribution,
style: const TextStyle(fontSize: 10, color: Colors.white),
style: TextStyle(fontSize: 10, color: Theme.of(context).colorScheme.onSurface),
),
),
),
),
if (_loading)
const Positioned(
left: 0,
right: 0,
top: 0,
child: LinearProgressIndicator(),
),
],
),
floatingActionButton: FloatingActionButton(
@@ -623,7 +631,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
const SizedBox(height: 4),
Text(
context.l10n.losBlockedSpotsHint,
style: TextStyle(fontSize: 11, color: Colors.grey[700]),
style: TextStyle(fontSize: 11, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
const SizedBox(height: 6),
Wrap(
@@ -692,7 +700,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
'${_selectedObstruction!.point.longitude.toStringAsFixed(5)}',
style: TextStyle(
fontSize: 11,
color: Colors.grey[700],
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
@@ -711,14 +719,14 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
context.l10n.losFrequencyLabel,
style: TextStyle(
fontSize: 11,
color: Colors.grey[700],
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 8),
Text(
'${displayFrequencyMHz.toStringAsFixed(3)} MHz',
style: TextStyle(fontSize: 11, color: Colors.grey[700]),
style: TextStyle(fontSize: 11, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
if (kFactorUsed != null) ...[
const SizedBox(width: 8),
@@ -726,7 +734,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
'k=${kFactorUsed.toStringAsFixed(3)}',
style: TextStyle(
fontSize: 11,
color: Colors.grey[700],
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 4),
@@ -734,7 +742,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
icon: const Icon(Icons.info_outline, size: 16),
color: Colors.grey[600],
color: Theme.of(context).colorScheme.onSurfaceVariant,
tooltip: context.l10n.losFrequencyInfoTooltip,
onPressed: () {
_showFrequencyInfoDialog(
@@ -750,7 +758,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
),
Text(
context.l10n.losElevationAttribution,
style: TextStyle(fontSize: 10, color: Colors.grey[700]),
style: TextStyle(fontSize: 10, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
const SizedBox(height: 6),
ExpansionTile(
@@ -1730,12 +1738,12 @@ class _LosLegend extends StatelessWidget {
Widget build(BuildContext context) {
final textStyle =
Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.white70,
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 11,
fontWeight: FontWeight.w500,
) ??
const TextStyle(
color: Colors.white70,
TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 11,
fontWeight: FontWeight.w500,
);
+4 -4
View File
@@ -83,17 +83,17 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
children: [
IconButton(
icon: const Icon(Icons.add),
tooltip: 'Zoom in',
tooltip: context.l10n.map_zoomIn,
onPressed: () => _zoomMapBy(1),
),
IconButton(
icon: const Icon(Icons.remove),
tooltip: 'Zoom out',
tooltip: context.l10n.map_zoomOut,
onPressed: () => _zoomMapBy(-1),
),
IconButton(
icon: const Icon(Icons.my_location),
tooltip: 'Center map',
tooltip: context.l10n.map_centerMap,
onPressed: _resetMapView,
),
],
@@ -458,7 +458,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
padding: const EdgeInsets.only(top: 8),
child: Text(
l10n.mapCache_failedDownloads(_failedTiles),
style: TextStyle(color: Colors.orange[700]),
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
+202 -169
View File
@@ -183,17 +183,17 @@ class _MapScreenState extends State<MapScreen> {
children: [
IconButton(
icon: const Icon(Icons.add),
tooltip: 'Zoom in',
tooltip: context.l10n.map_zoomIn,
onPressed: () => _zoomMapBy(1),
),
IconButton(
icon: const Icon(Icons.remove),
tooltip: 'Zoom out',
tooltip: context.l10n.map_zoomOut,
onPressed: () => _zoomMapBy(-1),
),
IconButton(
icon: const Icon(Icons.my_location),
tooltip: 'Center map',
tooltip: context.l10n.map_centerMap,
onPressed: () => _mapController.move(center, zoom),
),
],
@@ -417,61 +417,76 @@ class _MapScreenState extends State<MapScreen> {
automaticallyImplyLeading: false,
bottom: const SyncProgressAppBarBottom(),
actions: [
if (!_isBuildingPathTrace)
IconButton(
icon: const Icon(Icons.radar),
onPressed: () => _startPath(
LatLng(connector.selfLatitude!, connector.selfLongitude!),
),
tooltip: context.l10n.contacts_pathTrace,
),
if (!_isBuildingPathTrace)
IconButton(
icon: const LosIcon(),
onPressed: () {
final candidates = <LineOfSightEndpoint>[];
if (connector.selfLatitude != null &&
connector.selfLongitude != null) {
candidates.add(
LineOfSightEndpoint(
label: context.l10n.pathTrace_you,
point: LatLng(
connector.selfLatitude!,
connector.selfLongitude!,
),
color: Colors.teal,
icon: Icons.person_pin_circle,
),
);
}
for (final c in contactsWithLocation) {
candidates.add(
LineOfSightEndpoint(
label: c.name,
point: LatLng(c.latitude!, c.longitude!),
color: _getNodeColor(c.type),
icon: _getNodeIcon(c.type),
),
);
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LineOfSightMapScreen(
title: context.l10n.map_losScreenTitle,
candidates: candidates,
),
),
);
},
tooltip: context.l10n.map_lineOfSight,
),
PopupMenuButton(
itemBuilder: (context) => [
if (!_isBuildingPathTrace &&
connector.selfLatitude != null &&
connector.selfLongitude != null)
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.radar),
const SizedBox(width: 8),
Text(context.l10n.contacts_pathTrace),
],
),
onTap: () => _startPath(
LatLng(
connector.selfLatitude!,
connector.selfLongitude!,
),
),
),
if (!_isBuildingPathTrace)
PopupMenuItem(
child: Row(
children: [
const LosIcon(),
const SizedBox(width: 8),
Text(context.l10n.map_lineOfSight),
],
),
onTap: () {
final candidates = <LineOfSightEndpoint>[];
if (connector.selfLatitude != null &&
connector.selfLongitude != null) {
candidates.add(
LineOfSightEndpoint(
label: context.l10n.pathTrace_you,
point: LatLng(
connector.selfLatitude!,
connector.selfLongitude!,
),
color: Colors.teal,
icon: Icons.person_pin_circle,
),
);
}
for (final c in contactsWithLocation) {
candidates.add(
LineOfSightEndpoint(
label: c.name,
point: LatLng(c.latitude!, c.longitude!),
color: _getNodeColor(c.type),
icon: _getNodeIcon(c.type),
),
);
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LineOfSightMapScreen(
title: context.l10n.map_losScreenTitle,
candidates: candidates,
),
),
);
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.logout, color: Colors.red),
Icon(Icons.logout, color: Theme.of(context).colorScheme.error),
const SizedBox(width: 8),
Text(context.l10n.common_disconnect),
],
@@ -906,8 +921,8 @@ class _MapScreenState extends State<MapScreen> {
final color = _getNodeColor(guess.contact.type);
final marker = Marker(
point: guess.position,
width: 35,
height: 35,
width: 48,
height: 48,
child: GestureDetector(
onLongPress: () => _isBuildingPathTrace
? _showNodeInfo(context, guess.contact)
@@ -919,26 +934,28 @@ class _MapScreenState extends State<MapScreen> {
guess.contact,
guessedPosition: guess.position,
),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: color.withValues(
alpha: guess.highConfidence ? 0.55 : 0.30,
),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
child: Center(
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: color.withValues(
alpha: guess.highConfidence ? 0.55 : 0.30,
),
],
),
child: const Icon(
Icons.not_listed_location,
color: Colors.white,
size: 20,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: const Icon(
Icons.not_listed_location,
color: Colors.white,
size: 20,
),
),
),
),
@@ -1030,39 +1047,37 @@ class _MapScreenState extends State<MapScreen> {
for (final contact in filteredContacts) {
final marker = Marker(
point: LatLng(contact.latitude!, contact.longitude!),
width: 35,
height: 35,
width: 48,
height: 48,
child: GestureDetector(
onLongPress: () =>
_isBuildingPathTrace ? _showNodeInfo(context, contact) : null,
onTap: () => _isBuildingPathTrace
? _addToPath(context, contact)
: _showNodeInfo(context, contact),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: settings.mapShowOverlaps && !_isBuildingPathTrace
? Colors.red
: _getNodeColor(contact.type),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Icon(
_getNodeIcon(contact.type),
color: Colors.white,
size: 20,
),
child: Center(
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: settings.mapShowOverlaps && !_isBuildingPathTrace
? Colors.red
: _getNodeColor(contact.type),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
],
child: Icon(
_getNodeIcon(contact.type),
color: Colors.white,
size: 20,
),
),
),
),
);
@@ -1208,7 +1223,7 @@ class _MapScreenState extends State<MapScreen> {
Icon(
Icons.location_on,
size: 16,
color: Colors.grey,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
Text(
": $nodeCount",
@@ -1221,10 +1236,10 @@ class _MapScreenState extends State<MapScreen> {
),
Row(
children: [
const Icon(
Icon(
Icons.wrong_location,
size: 16,
color: Colors.grey,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
Text(
": ${nodeCountAll - nodeCount}",
@@ -1237,10 +1252,10 @@ class _MapScreenState extends State<MapScreen> {
),
Row(
children: [
const Icon(
Icon(
Icons.add_outlined,
size: 16,
color: Colors.grey,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
Text(
": $nodeCountAll",
@@ -1536,56 +1551,14 @@ class _MapScreenState extends State<MapScreen> {
LatLng? guessedPosition,
}) {
final connector = context.read<MeshCoreConnector>();
showDialog(
showModalBottomSheet(
context: context,
builder: (dialogContext) => AlertDialog(
title: Row(
children: [
Icon(
_getNodeIcon(contact.type),
color: _getNodeColor(contact.type),
),
const SizedBox(width: 8),
Expanded(child: SelectableText(contact.name)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow(
context.l10n.map_type,
contact.typeLabel(context.l10n),
),
_buildInfoRow(
context.l10n.map_path,
contact.pathLabel(context.l10n),
),
if (contact.hasLocation)
_buildInfoRow(
context.l10n.map_location,
'${contact.latitude!.toStringAsFixed(6)}, ${contact.longitude!.toStringAsFixed(6)}',
)
else if (guessedPosition != null)
_buildInfoRow(
context.l10n.map_estLocation,
'~${guessedPosition.latitude.toStringAsFixed(6)}, ${guessedPosition.longitude.toStringAsFixed(6)}',
),
_buildInfoRow(
context.l10n.map_lastSeen,
_formatLastSeen(contact.lastSeen),
),
_buildInfoRow(context.l10n.map_publicKey, contact.publicKeyHex),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(context.l10n.common_close),
),
if (contact.type ==
advTypeChat) // Only show chat button for chat nodes
TextButton(
showDragHandle: true,
builder: (sheetContext) {
final actions = <Widget>[];
if (contact.type == advTypeChat) {
actions.add(
FilledButton(
onPressed: () {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
@@ -1593,7 +1566,7 @@ class _MapScreenState extends State<MapScreen> {
final unread = connector.getUnreadCountForContactKey(
contact.publicKeyHex,
);
Navigator.pop(dialogContext);
Navigator.pop(sheetContext);
Navigator.push(
context,
MaterialPageRoute(
@@ -1606,30 +1579,88 @@ class _MapScreenState extends State<MapScreen> {
},
child: Text(context.l10n.contacts_openChat),
),
if (contact.type == advTypeRepeater)
TextButton(
);
}
if (contact.type == advTypeRepeater) {
actions.add(
FilledButton(
onPressed: () {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
}
Navigator.pop(dialogContext);
Navigator.pop(sheetContext);
_showRepeaterLogin(context, contact);
},
child: Text(context.l10n.map_manageRepeater),
),
if (contact.type == advTypeRoom)
TextButton(
);
}
if (contact.type == advTypeRoom) {
actions.add(
FilledButton(
onPressed: () {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
}
Navigator.pop(dialogContext);
Navigator.pop(sheetContext);
_showRoomLogin(context, contact);
},
child: Text(context.l10n.map_joinRoom),
),
],
),
);
}
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_getNodeIcon(contact.type),
color: _getNodeColor(contact.type),
),
const SizedBox(width: 8),
Expanded(child: SelectableText(contact.name)),
],
),
const SizedBox(height: 8),
_buildInfoRow(
context.l10n.map_type,
contact.typeLabel(context.l10n),
),
_buildInfoRow(
context.l10n.map_path,
contact.pathLabel(context.l10n),
),
if (contact.hasLocation)
_buildInfoRow(
context.l10n.map_location,
'${contact.latitude!.toStringAsFixed(6)}, ${contact.longitude!.toStringAsFixed(6)}',
)
else if (guessedPosition != null)
_buildInfoRow(
context.l10n.map_estLocation,
'~${guessedPosition.latitude.toStringAsFixed(6)}, ${guessedPosition.longitude.toStringAsFixed(6)}',
),
_buildInfoRow(
context.l10n.map_lastSeen,
_formatLastSeen(contact.lastSeen),
),
_buildInfoRow(context.l10n.map_publicKey, contact.publicKeyHex),
const SizedBox(height: 16),
...actions,
TextButton(
onPressed: () => Navigator.pop(sheetContext),
child: Text(context.l10n.common_close),
),
],
),
),
);
},
);
}
@@ -1714,6 +1745,9 @@ class _MapScreenState extends State<MapScreen> {
child: Text(context.l10n.common_hide),
),
TextButton(
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
onPressed: () async {
setState(() {
_hiddenMarkerIds.add(marker.id);
@@ -1745,7 +1779,7 @@ class _MapScreenState extends State<MapScreen> {
label,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
@@ -1810,9 +1844,8 @@ class _MapScreenState extends State<MapScreen> {
);
await connector.refreshDeviceInfo();
if (!mounted) return;
showDismissibleSnackBar(
messenger.context,
content: Text(successMsg),
messenger.showSnackBar(
SnackBar(content: Text(successMsg)),
);
},
),
@@ -2202,7 +2235,7 @@ class _MapScreenState extends State<MapScreen> {
const SizedBox(height: 8),
Text(
_getTimeFilterLabel(settings.mapTimeFilterHours),
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
Slider(
value: _hoursToSliderValue(settings.mapTimeFilterHours),
@@ -2354,7 +2387,7 @@ class _MapScreenState extends State<MapScreen> {
if (_pathTrace.isNotEmpty)
Text(
"${l10n.path_currentPathLabel} ${formatDistance(getPathDistanceMeters(_points), isImperial: isImperial)}",
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
SelectableText(
_pathTrace
+17 -73
View File
@@ -9,7 +9,8 @@ import '../models/path_selection.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../services/repeater_command_service.dart';
import '../widgets/path_management_dialog.dart';
import '../widgets/empty_state.dart';
import '../widgets/routing_sheet.dart';
import '../widgets/snr_indicator.dart';
import '../helpers/snack_bar_builder.dart';
@@ -167,7 +168,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
showDismissibleSnackBar(
context,
content: Text(context.l10n.neighbors_receivedData),
backgroundColor: Colors.green,
backgroundColor: Theme.of(context).colorScheme.tertiary,
);
_statusTimeout?.cancel();
if (!mounted) return;
@@ -227,7 +228,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
showDismissibleSnackBar(
context,
content: Text(context.l10n.neighbors_requestTimedOut),
backgroundColor: Colors.red,
backgroundColor: Theme.of(context).colorScheme.error,
);
_recordStatusResult(false);
});
@@ -241,7 +242,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
showDismissibleSnackBar(
context,
content: Text(context.l10n.neighbors_errorLoading(e.toString())),
backgroundColor: Colors.red,
backgroundColor: Theme.of(context).colorScheme.error,
);
}
}
@@ -279,7 +280,9 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
children: [
Text(
l10n.neighbors_repeatersNeighbors,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
repeater.name,
@@ -287,75 +290,18 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
fontSize: 14,
fontWeight: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
centerTitle: false,
actions: [
PopupMenuButton<String>(
IconButton(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
} else {
await connector.setPathOverride(repeater, pathLen: null);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
],
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement,
onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
ContactRoutingSheet.show(context, contact: repeater),
),
IconButton(
icon: _isLoading
@@ -380,11 +326,9 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
if (!_isLoaded &&
!_hasData &&
(_parsedNeighbors == null || _parsedNeighbors!.isEmpty))
Center(
child: Text(
l10n.neighbors_noData,
style: TextStyle(fontSize: 16, color: Colors.grey),
),
EmptyState(
icon: Icons.wifi_find,
title: l10n.neighbors_noData,
),
if (_isLoaded ||
_hasData &&
@@ -435,7 +379,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
fmtDuration(entry.value['lastHeard'] + 0.0),
),
entry.value['snr'],
connector.currentSf!,
connector.currentSf,
),
],
),
@@ -447,7 +391,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
String label,
String value,
double snr,
int spreadingFactor,
int? spreadingFactor,
) {
final snrUi = snrUiFromSNR(snr, spreadingFactor);
return Padding(
+136 -78
View File
@@ -103,6 +103,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
double _pathDistanceMeters = 0.0;
bool _showNodeLabels = true;
Contact? _targetContact;
// Live path resolved at trace time; used by the response handler for
// endpoint inference so it matches the path that was actually traced.
Uint8List _tracedPath = Uint8List(0);
String _formatPathPrefixes(Uint8List pathBytes) {
return pathBytes
@@ -168,17 +171,17 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
children: [
IconButton(
icon: const Icon(Icons.add),
tooltip: 'Zoom in',
tooltip: context.l10n.map_zoomIn,
onPressed: () => _zoomMapBy(1),
),
IconButton(
icon: const Icon(Icons.remove),
tooltip: 'Zoom out',
tooltip: context.l10n.map_zoomOut,
onPressed: () => _zoomMapBy(-1),
),
IconButton(
icon: const Icon(Icons.my_location),
tooltip: 'Center map',
tooltip: context.l10n.map_centerMap,
onPressed: _resetMapView,
),
],
@@ -228,6 +231,23 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
return traceBytes;
}
/// Resolves the path bytes to trace. When tracing a specific contact's
/// route (flipPathAround), re-read that contact's live forced/auto path from
/// the connector so a path the user just changed (force flood / set path /
/// reset to auto) is honored immediately, instead of the value captured when
/// this screen was first pushed.
Uint8List _resolveLivePath(MeshCoreConnector connector) {
final target = widget.targetContact;
if (!widget.flipPathAround || target == null) {
return widget.path;
}
final live = connector.allContactsUnfiltered.firstWhere(
(c) => c.publicKeyHex == target.publicKeyHex,
orElse: () => target,
);
return live.pathBytesForDisplay;
}
Future<void> _doPathTrace() async {
if (mounted) {
setState(() {
@@ -236,9 +256,13 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
});
}
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final livePath = _resolveLivePath(connector);
_tracedPath = livePath;
final pathTmp = widget.reversePathAround
? Uint8List.fromList(widget.path.reversed.toList())
: widget.path;
? Uint8List.fromList(livePath.reversed.toList())
: livePath;
final path = widget.flipPathAround ? buildPath(pathTmp) : pathTmp;
@@ -248,7 +272,6 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
noNotify: !mounted,
);
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final frame = buildTraceReq(
DateTime.now().millisecondsSinceEpoch ~/ 1000,
0, //flags
@@ -414,13 +437,13 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
final tc = _targetContact!;
if (tc.hasLocation) {
targetPos = LatLng(tc.latitude!, tc.longitude!);
} else if (widget.path.length > 1) {
} else if (_tracedPath.length > 1) {
// Infer from the last hop: average GPS contacts sharing that hop.
// For a round-trip path (flipPathAround/reversePathAround), the target-side hop
// sits in the middle of the symmetric sequence; .last is the local side.
final lastHop = widget.reversePathAround
? widget.path.first
: widget.path.last;
? _tracedPath.first
: _tracedPath.last;
final peers = connector.allContacts
.where(
@@ -593,7 +616,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
!_failed2Loaded)
Center(
child: Card(
color: Colors.white.withValues(alpha: 0.9),
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9),
child: Padding(
padding: EdgeInsets.all(12),
child: Text(
@@ -640,31 +663,35 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
markers.add(
Marker(
point: point,
width: 35,
height: 35,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: hasGps
? Colors.green
: Colors.orange.withValues(alpha: 0.75),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
width: 48,
height: 48,
child: Center(
child: Container(
width: 35,
height: 35,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: hasGps
? Colors.green
: Colors.orange.withValues(alpha: 0.75),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: Text(
hasGps ? label : '~$label',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
],
),
alignment: Alignment.center,
child: Text(
hasGps ? label : '~$label',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
@@ -689,29 +716,33 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
markers.add(
Marker(
point: selfPoint,
width: 35,
height: 35,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
width: 48,
height: 48,
child: Center(
child: Container(
width: 35,
height: 35,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: Text(
context.l10n.pathTrace_you,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
],
),
alignment: Alignment.center,
child: Text(
context.l10n.pathTrace_you,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
@@ -735,26 +766,30 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
markers.add(
Marker(
point: targetPos,
width: 35,
height: 35,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: isGuessed
? Colors.purple.withValues(alpha: 0.55)
: Colors.red,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
width: 48,
height: 48,
child: Center(
child: Container(
width: 35,
height: 35,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: isGuessed
? Colors.purple.withValues(alpha: 0.55)
: Colors.red,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: const Icon(Icons.person, color: Colors.white, size: 18),
),
alignment: Alignment.center,
child: const Icon(Icons.person, color: Colors.white, size: 18),
),
),
);
@@ -927,6 +962,12 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
);
}
Widget _colorDot(Color color) => Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
);
Widget _buildLegendCard(
BuildContext context,
PathTraceData pathTraceData,
@@ -949,9 +990,26 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Text(
'${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistanceMeters, isImperial: isImperial)}',
style: const TextStyle(fontWeight: FontWeight.w600),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistanceMeters, isImperial: isImperial)}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 6),
Row(
children: [
_colorDot(Colors.green),
const SizedBox(width: 4),
Text(l10n.pathTrace_legendGpsConfirmed, style: const TextStyle(fontSize: 11)),
const SizedBox(width: 12),
_colorDot(Colors.orange),
const SizedBox(width: 4),
Text(l10n.pathTrace_legendInferred, style: const TextStyle(fontSize: 11)),
],
),
],
),
),
const Divider(height: 1),
+40 -81
View File
@@ -8,7 +8,7 @@ import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../widgets/debug_frame_viewer.dart';
import '../services/repeater_command_service.dart';
import '../widgets/path_management_dialog.dart';
import '../widgets/routing_sheet.dart';
import '../helpers/snack_bar_builder.dart';
class RepeaterCliScreen extends StatefulWidget {
@@ -252,97 +252,29 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.repeater_cliTitle),
Text(
l10n.repeater_cliTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
repeater.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
centerTitle: false,
actions: [
PopupMenuButton<String>(
IconButton(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
} else {
await connector.setPathOverride(repeater, pathLen: null);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
],
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement,
onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
),
IconButton(
icon: const Icon(Icons.bug_report),
tooltip: l10n.repeater_debugNextCommand,
onPressed: () {
// Set a flag or just send next command with debug
if (_commandController.text.trim().isNotEmpty) {
_sendCommand(showDebug: true);
} else {
showDismissibleSnackBar(
context,
content: Text(l10n.repeater_enterCommandFirst),
);
}
},
ContactRoutingSheet.show(context, contact: repeater),
),
IconButton(
icon: const Icon(Icons.help_outline),
@@ -354,6 +286,33 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
tooltip: l10n.repeater_clearHistory,
onPressed: _commandHistory.isEmpty ? null : _clearHistory,
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
if (value == 'debug') {
if (_commandController.text.trim().isNotEmpty) {
_sendCommand(showDebug: true);
} else {
showDismissibleSnackBar(
context,
content: Text(l10n.repeater_enterCommandFirst),
);
}
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'debug',
child: Row(
children: [
const Icon(Icons.bug_report),
const SizedBox(width: 8),
Text(l10n.repeater_debugNextCommand),
],
),
),
],
),
],
),
body: Column(
@@ -426,16 +385,16 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.terminal, size: 64, color: Colors.grey[400]),
Icon(Icons.terminal, size: 64, color: Theme.of(context).colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text(
l10n.repeater_noCommandsSent,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
style: TextStyle(fontSize: 16, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
const SizedBox(height: 8),
Text(
l10n.repeater_typeCommandOrUseQuick,
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
],
),
+14 -14
View File
@@ -72,11 +72,11 @@ class RepeaterHubScreen extends StatelessWidget {
children: [
CircleAvatar(
radius: 40,
backgroundColor: Colors.orange,
child: const Icon(
backgroundColor: Theme.of(context).colorScheme.tertiaryContainer,
child: Icon(
Icons.cell_tower,
size: 40,
color: Colors.white,
color: Theme.of(context).colorScheme.onTertiaryContainer,
),
),
const SizedBox(height: 16),
@@ -90,12 +90,12 @@ class RepeaterHubScreen extends StatelessWidget {
const SizedBox(height: 8),
Text(
repeater.shortPubKeyHex,
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
const SizedBox(height: 8),
Text(
repeater.pathLabel(context.l10n),
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
if (repeater.hasLocation) ...[
const SizedBox(height: 4),
@@ -105,14 +105,14 @@ class RepeaterHubScreen extends StatelessWidget {
Icon(
Icons.location_on,
size: 14,
color: Colors.grey[600],
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'${repeater.latitude?.toStringAsFixed(4)}, ${repeater.longitude?.toStringAsFixed(4)}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
@@ -193,7 +193,7 @@ class RepeaterHubScreen extends StatelessWidget {
icon: Icons.analytics,
title: l10n.repeater_status,
subtitle: l10n.repeater_statusSubtitle,
color: Colors.blue,
color: Theme.of(context).colorScheme.primary,
onTap: () {
Navigator.push(
context,
@@ -213,7 +213,7 @@ class RepeaterHubScreen extends StatelessWidget {
icon: Icons.bar_chart_sharp,
title: l10n.repeater_telemetry,
subtitle: l10n.repeater_telemetrySubtitle,
color: Colors.teal,
color: Theme.of(context).colorScheme.secondary,
onTap: () {
Navigator.push(
context,
@@ -231,7 +231,7 @@ class RepeaterHubScreen extends StatelessWidget {
icon: Icons.terminal,
title: l10n.repeater_cli,
subtitle: l10n.repeater_cliSubtitle,
color: Colors.green,
color: Theme.of(context).colorScheme.tertiary,
onTap: () {
Navigator.push(
context,
@@ -251,7 +251,7 @@ class RepeaterHubScreen extends StatelessWidget {
icon: Icons.group,
title: l10n.repeater_neighbors,
subtitle: l10n.repeater_neighborsSubtitle,
color: Colors.orange,
color: Theme.of(context).colorScheme.tertiary,
onTap: () {
Navigator.push(
context,
@@ -270,7 +270,7 @@ class RepeaterHubScreen extends StatelessWidget {
icon: Icons.settings,
title: l10n.repeater_settings,
subtitle: l10n.repeater_settingsSubtitle,
color: Colors.deepOrange,
color: Theme.of(context).colorScheme.error,
onTap: () {
Navigator.push(
context,
@@ -329,12 +329,12 @@ class RepeaterHubScreen extends StatelessWidget {
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
],
),
),
Icon(Icons.chevron_right, color: Colors.grey[400]),
Icon(Icons.chevron_right, color: Theme.of(context).colorScheme.onSurfaceVariant),
],
),
),
+46 -79
View File
@@ -8,7 +8,7 @@ import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../services/repeater_command_service.dart';
import '../services/storage_service.dart';
import '../widgets/path_management_dialog.dart';
import '../widgets/routing_sheet.dart';
import '../helpers/snack_bar_builder.dart';
class RepeaterSettingsScreen extends StatefulWidget {
@@ -126,6 +126,8 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
// Location settings
final TextEditingController _latController = TextEditingController();
final TextEditingController _lonController = TextEditingController();
bool _latInvalid = false;
bool _lonInvalid = false;
// Feature toggles
bool _repeatEnabled = true;
@@ -457,7 +459,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
? l10n.repeater_refreshed(label)
: l10n.repeater_errorRefreshing(label),
),
backgroundColor: successCount > 0 ? Colors.green : Colors.red,
backgroundColor: successCount > 0 ? null : Theme.of(context).colorScheme.error,
);
setState(() => setRefreshing(false));
}
@@ -667,15 +669,15 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
: l10n.repeater_actionSucceeded(label),
),
backgroundColor: outcome == _SaveOutcome.error
? Colors.red
: Colors.green,
? Theme.of(context).colorScheme.error
: null,
);
} catch (e) {
if (!mounted) return;
showDismissibleSnackBar(
context,
content: Text(l10n.repeater_actionFailed(label, e.toString())),
backgroundColor: Colors.red,
backgroundColor: Theme.of(context).colorScheme.error,
);
} finally {
if (mounted) setState(() => _runningAction = false);
@@ -768,14 +770,16 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}
if (_dirtyFields.contains(_SettingField.lat) &&
_latController.text.isNotEmpty) {
_latController.text.isNotEmpty &&
_isValidCoordinate(_latController.text, 90)) {
pending.add((
field: _SettingField.lat,
command: 'set lat ${_latController.text}',
));
}
if (_dirtyFields.contains(_SettingField.lon) &&
_lonController.text.isNotEmpty) {
_lonController.text.isNotEmpty &&
_isValidCoordinate(_lonController.text, 180)) {
pending.add((
field: _SettingField.lon,
command: 'set lon ${_lonController.text}',
@@ -944,13 +948,12 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
showDismissibleSnackBar(
context,
content: Text(l10n.repeater_settingsSavedRebootNeeded),
backgroundColor: Colors.orange,
backgroundColor: Theme.of(context).colorScheme.tertiary,
);
} else if (failures.isEmpty) {
showDismissibleSnackBar(
context,
content: Text(l10n.repeater_settingsSaved),
backgroundColor: Colors.green,
);
} else {
showDismissibleSnackBar(
@@ -958,7 +961,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
content: Text(
l10n.repeater_settingsPartialFailure(failures.join('; ')),
),
backgroundColor: Colors.red,
backgroundColor: Theme.of(context).colorScheme.error,
);
}
}
@@ -973,7 +976,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
content: Text(
context.l10n.repeater_errorSavingSettings(e.toString()),
),
backgroundColor: Colors.red,
backgroundColor: Theme.of(context).colorScheme.error,
);
}
}
@@ -984,6 +987,12 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
_flagHasChanges();
}
static bool _isValidCoordinate(String text, double max) {
if (text.trim().isEmpty) return true;
final value = double.tryParse(text.trim());
return value != null && value >= -max && value <= max;
}
void _flagHasChanges() {
if (!_hasChanges) {
setState(() {
@@ -1072,73 +1081,11 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
),
centerTitle: false,
actions: [
PopupMenuButton<String>(
IconButton(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
} else {
await connector.setPathOverride(repeater, pathLen: null);
}
if (mounted) {
setState(() {});
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
],
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement,
onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
ContactRoutingSheet.show(context, contact: repeater),
),
if (_hasChanges)
TextButton.icon(
@@ -1173,6 +1120,8 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
const SizedBox(height: 16),
_buildAdvancedCard(),
const SizedBox(height: 32),
const Divider(),
const SizedBox(height: 16),
_buildDangerZoneCard(),
],
),
@@ -1388,13 +1337,22 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
decoration: InputDecoration(
labelText: l10n.repeater_latitude,
helperText: l10n.repeater_latitudeHelper,
errorText: _latInvalid
? l10n.settings_locationInvalid
: null,
border: const OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
onChanged: (_) => _markChanged(_SettingField.lat),
onChanged: (value) {
_markChanged(_SettingField.lat);
final invalid = !_isValidCoordinate(value, 90);
if (invalid != _latInvalid) {
setState(() => _latInvalid = invalid);
}
},
),
),
const SizedBox(width: 8),
@@ -1415,13 +1373,22 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
decoration: InputDecoration(
labelText: l10n.repeater_longitude,
helperText: l10n.repeater_longitudeHelper,
errorText: _lonInvalid
? l10n.settings_locationInvalid
: null,
border: const OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
onChanged: (_) => _markChanged(_SettingField.lon),
onChanged: (value) {
_markChanged(_SettingField.lon);
final invalid = !_isValidCoordinate(value, 180);
if (invalid != _lonInvalid) {
setState(() => _lonInvalid = invalid);
}
},
),
),
const SizedBox(width: 8),
@@ -2233,7 +2200,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
showDismissibleSnackBar(
context,
content: Text(l10n.repeater_errorSendingCommand(e.toString())),
backgroundColor: Colors.red,
backgroundColor: Theme.of(context).colorScheme.error,
);
}
}
@@ -2262,7 +2229,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
onConfirm();
},
style: isDestructive
? FilledButton.styleFrom(backgroundColor: Colors.red)
? FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error)
: null,
child: Text(l10n.repeater_confirm),
),
+19 -73
View File
@@ -11,7 +11,7 @@ import '../connector/meshcore_protocol.dart';
import '../services/app_settings_service.dart';
import '../services/repeater_command_service.dart';
import '../utils/battery_utils.dart';
import '../widgets/path_management_dialog.dart';
import '../widgets/routing_sheet.dart';
import '../helpers/snack_bar_builder.dart';
class RepeaterStatusScreen extends StatefulWidget {
@@ -318,7 +318,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
showDismissibleSnackBar(
context,
content: Text(context.l10n.repeater_statusRequestTimeout),
backgroundColor: Colors.red,
backgroundColor: Theme.of(context).colorScheme.error,
);
_recordStatusResult(false);
});
@@ -331,7 +331,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
showDismissibleSnackBar(
context,
content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())),
backgroundColor: Colors.red,
backgroundColor: Theme.of(context).colorScheme.error,
);
}
_recordStatusResult(false);
@@ -360,82 +360,29 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.repeater_statusTitle),
Text(
l10n.repeater_statusTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
repeater.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
centerTitle: false,
actions: [
PopupMenuButton<String>(
IconButton(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
} else {
await connector.setPathOverride(repeater, pathLen: null);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
],
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement,
onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
ContactRoutingSheet.show(context, contact: repeater),
),
IconButton(
icon: _isLoading
@@ -588,21 +535,20 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 130,
Expanded(
child: Text(
label,
style: TextStyle(
color: Colors.grey[600],
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(fontWeight: FontWeight.w400),
),
const SizedBox(width: 8),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.w400),
textAlign: TextAlign.end,
),
],
),
+89 -89
View File
@@ -10,6 +10,7 @@ import '../services/linux_ble_error_classifier.dart';
import '../utils/app_logger.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/device_tile.dart';
import '../widgets/empty_state.dart';
import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart';
import 'tcp_screen.dart';
@@ -25,6 +26,7 @@ class ScannerScreen extends StatefulWidget {
class _ScannerScreenState extends State<ScannerScreen> {
bool _changedNavigation = false;
String? _connectingDeviceId;
late final MeshCoreConnector _connector;
late final VoidCallback _connectionListener;
BluetoothAdapterState _bluetoothState = BluetoothAdapterState.unknown;
@@ -101,6 +103,32 @@ class _ScannerScreenState extends State<ScannerScreen> {
title: AdaptiveAppBarTitle(context.l10n.scanner_title),
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
if (PlatformInfo.supportsUsbSerial)
IconButton(
icon: const Icon(Icons.usb),
tooltip: context.l10n.connectionChoiceUsbLabel,
onPressed: () {
appLogger.info(
'USB selected, opening UsbScreen',
tag: 'ScannerScreen',
);
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const UsbScreen()),
);
},
),
if (!PlatformInfo.isWeb)
IconButton(
icon: const Icon(Icons.lan),
tooltip: context.l10n.connectionChoiceTcpLabel,
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const TcpScreen()),
);
},
),
],
),
body: SafeArea(
top: false,
@@ -122,84 +150,24 @@ class _ScannerScreenState extends State<ScannerScreen> {
},
),
),
bottomNavigationBar: Consumer<MeshCoreConnector>(
floatingActionButton: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
final isScanning =
connector.state == MeshCoreConnectionState.scanning;
final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
final usbSupported = PlatformInfo.supportsUsbSerial;
final tcpSupported = !PlatformInfo.isWeb;
return SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (usbSupported)
FloatingActionButton.extended(
onPressed: () {
appLogger.info(
'USB selected, opening UsbScreen',
tag: 'ScannerScreen',
);
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const UsbScreen()),
);
},
heroTag: 'scanner_usb_action',
icon: const Icon(Icons.usb),
label: Text(context.l10n.connectionChoiceUsbLabel),
),
if (usbSupported) const SizedBox(width: 12),
if (tcpSupported)
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const TcpScreen()),
);
},
heroTag: 'scanner_tcp_action',
icon: const Icon(Icons.lan),
label: Text(context.l10n.connectionChoiceTcpLabel),
),
if (tcpSupported) const SizedBox(width: 12),
FloatingActionButton.extended(
heroTag: 'scanner_ble_action',
onPressed: isBluetoothOff
? null
: () {
if (isScanning) {
connector.stopScan();
} else {
unawaited(
connector.startScan().catchError((e) {
appLogger.warn(
'startScan error: $e',
tag: 'ScannerScreen',
);
}),
);
}
},
icon: isScanning
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.bluetooth_searching),
label: Text(
isScanning
? context.l10n.scanner_stop
: context.l10n.scanner_scan,
),
),
],
),
return FloatingActionButton.extended(
heroTag: 'scanner_ble_action',
onPressed: isBluetoothOff ? null : () => _toggleScan(connector),
icon: isScanning
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.bluetooth_searching),
label: Text(
isScanning ? context.l10n.scanner_stop : context.l10n.scanner_scan,
),
);
},
@@ -207,6 +175,18 @@ class _ScannerScreenState extends State<ScannerScreen> {
);
}
void _toggleScan(MeshCoreConnector connector) {
if (connector.state == MeshCoreConnectionState.scanning) {
connector.stopScan();
} else {
unawaited(
connector.startScan().catchError((e) {
appLogger.warn('startScan error: $e', tag: 'ScannerScreen');
}),
);
}
}
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
String statusText;
Color statusColor;
@@ -254,32 +234,43 @@ class _ScannerScreenState extends State<ScannerScreen> {
Widget _buildDeviceList(BuildContext context, MeshCoreConnector connector) {
if (connector.scanResults.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.bluetooth, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
connector.state == MeshCoreConnectionState.scanning
? context.l10n.scanner_searchingDevices
: context.l10n.scanner_tapToScan,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
),
final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
final isScanning = connector.state == MeshCoreConnectionState.scanning;
return EmptyState(
icon: isBluetoothOff ? Icons.bluetooth_disabled : Icons.bluetooth,
title: isBluetoothOff
? context.l10n.scanner_bluetoothOff
: isScanning
? context.l10n.scanner_searchingDevices
: context.l10n.scanner_tapToScan,
subtitle: isBluetoothOff
? context.l10n.scanner_bluetoothOffMessage
: null,
action: (isBluetoothOff || isScanning)
? null
: FilledButton.icon(
onPressed: () => _toggleScan(connector),
icon: const Icon(Icons.bluetooth_searching),
label: Text(context.l10n.scanner_scan),
),
);
}
final isConnecting =
connector.state == MeshCoreConnectionState.connecting;
return ListView.separated(
padding: const EdgeInsets.all(8),
itemCount: connector.scanResults.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
final result = connector.scanResults[index];
final deviceId = result.device.remoteId.toString();
return DeviceTile(
scanResult: result,
onTap: () => _connectToDevice(context, connector, result),
isConnecting: isConnecting && _connectingDeviceId == deviceId,
onTap: isConnecting
? null
: () => _connectToDevice(context, connector, result),
);
},
);
@@ -293,6 +284,9 @@ class _ScannerScreenState extends State<ScannerScreen> {
final name = result.device.platformName.isNotEmpty
? result.device.platformName
: result.advertisementData.advName;
setState(() {
_connectingDeviceId = result.device.remoteId.toString();
});
try {
await connector.connect(
result.device,
@@ -321,9 +315,15 @@ class _ScannerScreenState extends State<ScannerScreen> {
showDismissibleSnackBar(
context,
content: Text(context.l10n.scanner_connectionFailed(e.toString())),
backgroundColor: Colors.red,
backgroundColor: Theme.of(context).colorScheme.error,
);
}
} finally {
if (mounted) {
setState(() {
_connectingDeviceId = null;
});
}
}
}
+90 -35
View File
@@ -53,6 +53,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _loadVersionInfo() async {
final packageInfo = await PackageInfo.fromPlatform();
if (!mounted) return;
setState(() {
_appVersion = packageInfo.version;
});
@@ -213,12 +214,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (percent == null) {
icon = Icons.battery_unknown;
iconColor = Colors.grey;
iconColor = Theme.of(context).colorScheme.onSurfaceVariant;
valueColor = null;
} else if (percent <= 15) {
icon = Icons.battery_alert;
iconColor = Colors.orange;
valueColor = Colors.orange;
iconColor = Theme.of(context).colorScheme.tertiary;
valueColor = Theme.of(context).colorScheme.tertiary;
} else {
icon = Icons.battery_full;
iconColor = null;
@@ -307,18 +308,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Icons.chevron_right),
onTap: () => _editLocation(context, connector),
),
if (connector.currentCustomVars?.containsKey('gps') ?? false) ...[
const Divider(height: 1),
SwitchListTile(
secondary: const Icon(Icons.gps_fixed),
title: Text(l10n.settings_locationGPSEnable),
subtitle: Text(l10n.settings_locationGPSEnableSubtitle),
value: connector.currentCustomVars?['gps'] == '1',
onChanged: (value) async {
await connector.setCustomVar(value ? 'gps:1' : 'gps:0');
},
),
],
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.group_add_outlined),
@@ -354,13 +343,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.red),
leading: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error),
title: Text(l10n.settings_deleteAllPaths),
subtitle: Text(
l10n.settings_deleteAllPathsSubtitle,
style: TextStyle(color: Colors.red[700]),
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
onTap: () => connector.deleteAllPaths(),
onTap: () => _confirmDeleteAllPaths(context, connector),
),
const Divider(height: 1),
ListTile(
@@ -378,7 +367,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.restart_alt, color: Colors.orange),
leading: Icon(Icons.restart_alt, color: Theme.of(context).colorScheme.tertiary),
title: Text(l10n.settings_rebootDevice),
subtitle: Text(l10n.settings_rebootDeviceSubtitle),
onTap: () => _confirmReboot(context, connector),
@@ -565,6 +554,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
int.tryParse(customVars["gps_interval"] ?? "") ?? 900;
intervalController.text = currentInterval.toString();
String? intervalError;
showDialog(
context: context,
builder: (dialogContext) => StatefulBuilder(
@@ -600,9 +591,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 16),
TextField(
controller: intervalController,
onChanged: (_) {
if (intervalError != null) {
setDialogState(() => intervalError = null);
}
},
decoration: InputDecoration(
labelText: l10n.settings_locationIntervalSec,
border: const OutlineInputBorder(),
errorText: intervalError,
),
keyboardType: const TextInputType.numberWithOptions(
decimal: false,
@@ -633,24 +630,25 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
TextButton(
onPressed: () async {
Navigator.pop(context);
int? interval;
if (hasGPS) {
final intervalText = intervalController.text.trim();
if (intervalText.isEmpty) {
return;
if (intervalText.isNotEmpty) {
interval = int.tryParse(intervalText);
if (interval == null ||
interval < 60 ||
interval >= 86400) {
setDialogState(() {
intervalError = l10n.settings_locationIntervalInvalid;
});
return;
}
}
}
final interval = int.tryParse(intervalText);
if (interval == null || interval < 60 || interval >= 86400) {
if (!context.mounted) return;
showDismissibleSnackBar(
context,
content: Text(l10n.settings_locationIntervalInvalid),
);
return;
}
Navigator.pop(context);
if (interval != null) {
await connector.setCustomVar("gps_interval:$interval");
await connector.refreshDeviceInfo();
if (!context.mounted) return;
@@ -716,6 +714,36 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
void _confirmDeleteAllPaths(
BuildContext context,
MeshCoreConnector connector,
) {
final l10n = context.l10n;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.settings_deleteAllPaths),
content: Text(l10n.settings_deleteAllPathsSubtitle),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.common_cancel),
),
TextButton(
onPressed: () {
Navigator.pop(context);
connector.deleteAllPaths();
},
child: Text(
l10n.common_deleteAll,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
),
);
}
void _confirmReboot(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
showDialog(
@@ -735,7 +763,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
child: Text(
l10n.common_reboot,
style: const TextStyle(color: Colors.orange),
style: TextStyle(color: Theme.of(context).colorScheme.tertiary),
),
),
],
@@ -1126,6 +1154,8 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
bool _clientRepeat = false;
int? _selectedPresetIndex;
_RadioSettingsSnapshot? _lastNonRepeatSnapshot;
String? _frequencyError;
String? _txPowerError;
AppDebugLogService get _appLog =>
Provider.of<AppDebugLogService>(context, listen: false);
@@ -1392,7 +1422,24 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
void _handleManualSettingsChanged(String source) {
_logRadioSettingsState('Manual settings edit: $source');
setState(_syncPresetSelection);
setState(() {
_validateFields();
_syncPresetSelection();
});
}
void _validateFields() {
final l10n = context.l10n;
final freqMHz = double.tryParse(_frequencyController.text);
_frequencyError = (freqMHz == null || freqMHz < 300 || freqMHz > 2500)
? l10n.settings_frequencyInvalid
: null;
final maxTxPower = widget.connector.maxTxPower ?? 22;
final txPower = int.tryParse(_txPowerController.text);
_txPowerError = (txPower == null || txPower < 0 || txPower > maxTxPower)
? '${l10n.settings_txPowerInvalid} (0-$maxTxPower dBm)'
: null;
}
void _handleClientRepeatChanged(bool enabled) {
@@ -1504,6 +1551,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
content: Text(l10n.settings_error(e.toString())),
);
}
if (!mounted) return;
Navigator.pop(context);
}
@@ -1579,6 +1627,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
labelText: l10n.settings_frequency,
border: const OutlineInputBorder(),
helperText: l10n.settings_frequencyHelper,
errorText: _frequencyError,
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
@@ -1662,6 +1711,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
helperText: widget.connector.maxTxPower != null
? '${l10n.settings_txPowerHelper} (max: ${widget.connector.maxTxPower} dBm)'
: l10n.settings_txPowerHelper,
errorText: _txPowerError,
),
keyboardType: TextInputType.number,
),
@@ -1683,7 +1733,12 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
onPressed: () => Navigator.pop(context),
child: Text(l10n.common_cancel),
),
FilledButton(onPressed: _saveSettings, child: Text(l10n.common_save)),
FilledButton(
onPressed: (_frequencyError != null || _txPowerError != null)
? null
: _saveSettings,
child: Text(l10n.common_save),
),
],
);
}
+39 -47
View File
@@ -95,12 +95,14 @@ class _TcpScreenState extends State<TcpScreen> {
final isConnecting =
connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.tcp;
final isButtonDisabled =
isConnecting ||
connector.state == MeshCoreConnectionState.scanning;
// A running BLE scan must not block TCP connect: connectTcp() stops
// any active scan before connecting, so the only reason to disable
// the button is a TCP connect already in flight.
final isButtonDisabled = isConnecting;
return Column(
children: [
_buildStatusBar(context, connector),
_buildTransportLinks(context),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
@@ -154,40 +156,32 @@ class _TcpScreenState extends State<TcpScreen> {
},
),
),
bottomNavigationBar: SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (PlatformInfo.supportsUsbSerial)
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const UsbScreen()),
);
},
heroTag: 'tcp_usb_action',
extendedPadding: const EdgeInsets.symmetric(horizontal: 12),
icon: const Icon(Icons.usb),
label: Text(context.l10n.connectionChoiceUsbLabel),
),
if (PlatformInfo.supportsUsbSerial) const SizedBox(width: 12),
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).maybePop();
},
heroTag: 'tcp_ble_action',
extendedPadding: const EdgeInsets.symmetric(horizontal: 12),
icon: const Icon(Icons.bluetooth),
label: Text(context.l10n.connectionChoiceBluetoothLabel),
),
],
);
}
Widget _buildTransportLinks(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Wrap(
spacing: 12,
runSpacing: 8,
children: [
if (PlatformInfo.supportsUsbSerial)
OutlinedButton.icon(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const UsbScreen()),
);
},
icon: const Icon(Icons.usb),
label: Text(context.l10n.connectionChoiceUsbLabel),
),
OutlinedButton.icon(
onPressed: () => Navigator.of(context).maybePop(),
icon: const Icon(Icons.bluetooth),
label: Text(context.l10n.connectionChoiceBluetoothLabel),
),
),
],
),
);
}
@@ -214,7 +208,7 @@ class _TcpScreenState extends State<TcpScreen> {
statusColor = Colors.orange;
} else {
statusText = l10n.tcpStatus_notConnected;
statusColor = Colors.grey;
statusColor = Theme.of(context).colorScheme.onSurfaceVariant;
}
return Container(
@@ -226,15 +220,13 @@ class _TcpScreenState extends State<TcpScreen> {
Icon(Icons.circle, size: 12, color: statusColor),
const SizedBox(width: 8),
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
statusText,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w500,
),
child: Text(
statusText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w500,
),
),
),
@@ -274,7 +266,7 @@ class _TcpScreenState extends State<TcpScreen> {
showDismissibleSnackBar(
context,
content: Text(message),
backgroundColor: Colors.red,
backgroundColor: Theme.of(context).colorScheme.error,
);
}
+20 -77
View File
@@ -13,7 +13,7 @@ import '../connector/meshcore_protocol.dart';
import '../services/app_settings_service.dart';
import '../services/repeater_command_service.dart';
import '../utils/app_logger.dart';
import '../widgets/path_management_dialog.dart';
import '../widgets/routing_sheet.dart';
import '../helpers/cayenne_lpp.dart';
import '../utils/battery_utils.dart';
import '../helpers/snack_bar_builder.dart';
@@ -118,7 +118,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
showDismissibleSnackBar(
context,
content: Text(context.l10n.telemetry_requestTimeout),
backgroundColor: Colors.red,
backgroundColor: Theme.of(context).colorScheme.error,
);
}
if (isAutoRefreshRequest && _isAutoRefreshEnabled) {
@@ -178,7 +178,6 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
showDismissibleSnackBar(
context,
content: Text(context.l10n.telemetry_receivedData),
backgroundColor: Colors.green,
);
}
_statusTimeout?.cancel();
@@ -235,7 +234,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
showDismissibleSnackBar(
context,
content: Text(context.l10n.telemetry_errorLoading(e.toString())),
backgroundColor: Colors.red,
backgroundColor: Theme.of(context).colorScheme.error,
);
}
}
@@ -323,7 +322,11 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
final connector = context.watch<MeshCoreConnector>();
final settings = context.watch<AppSettingsService>().settings;
final isImperialUnits = settings.unitSystem == UnitSystem.imperial;
final isFloodMode = widget.contact.pathOverride == -1;
final contact = connector.contacts.firstWhere(
(c) => c.publicKeyHex == widget.contact.publicKeyHex,
orElse: () => widget.contact,
);
final isFloodMode = contact.pathOverride == -1;
return Scaffold(
appBar: AppBar(
@@ -347,70 +350,11 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
centerTitle: false,
bottom: const SyncProgressAppBarBottom(),
actions: [
PopupMenuButton<String>(
IconButton(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(widget.contact, pathLen: -1);
} else {
await connector.setPathOverride(widget.contact, pathLen: null);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
],
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement,
onPressed: () =>
PathManagementDialog.show(context, contact: widget.contact),
ContactRoutingSheet.show(context, contact: widget.contact),
),
IconButton(
icon: _isLoading
@@ -441,7 +385,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
Center(
child: Text(
l10n.telemetry_noData,
style: const TextStyle(fontSize: 16, color: Colors.grey),
style: TextStyle(fontSize: 16, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
),
if ((_isLoaded || _hasData) &&
@@ -718,14 +662,14 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
alignment: Alignment.center,
children: [
Center(child: Text(l10n.common_disable)),
const Positioned(
Positioned(
right: 0,
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
@@ -971,21 +915,20 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 130,
Expanded(
child: Text(
label,
style: TextStyle(
color: Colors.grey[600],
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(fontWeight: FontWeight.w400),
),
const SizedBox(width: 8),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.w400),
textAlign: TextAlign.end,
),
],
),
+60 -85
View File
@@ -12,7 +12,6 @@ import '../utils/usb_port_labels.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart';
import 'scanner_screen.dart';
import 'tcp_screen.dart';
class UsbScreen extends StatefulWidget {
@@ -100,81 +99,62 @@ class _UsbScreenState extends State<UsbScreen> {
return Column(
children: [
_buildStatusBar(context, connector),
_buildTransportLinks(context),
Expanded(child: _buildPortList(context, connector)),
],
);
},
),
),
bottomNavigationBar: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
final isLoading = _isLoadingPorts;
final showBle = true;
final showTcp = !PlatformInfo.isWeb;
return SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
bottomNavigationBar: _supportsHotPlug
? null
: SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (showTcp)
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const TcpScreen()),
);
},
heroTag: 'usb_tcp_action',
extendedPadding: const EdgeInsets.symmetric(
horizontal: 12,
),
icon: const Icon(Icons.lan),
label: Text(context.l10n.connectionChoiceTcpLabel),
),
if (showTcp && showBle) const SizedBox(width: 12),
if (showBle)
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => const ScannerScreen(),
),
);
},
heroTag: 'usb_ble_action',
extendedPadding: const EdgeInsets.symmetric(
horizontal: 12,
),
icon: const Icon(Icons.bluetooth),
label: Text(context.l10n.connectionChoiceBluetoothLabel),
),
if ((showTcp || showBle) && !_supportsHotPlug)
const SizedBox(width: 12),
if (!_supportsHotPlug)
FloatingActionButton.extended(
onPressed: isLoading ? null : _loadPorts,
heroTag: 'usb_refresh_action',
extendedPadding: const EdgeInsets.symmetric(
horizontal: 12,
),
icon: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.usb),
label: Text(context.l10n.scanner_scan),
),
FloatingActionButton.extended(
onPressed: _isLoadingPorts ? null : _loadPorts,
heroTag: 'usb_refresh_action',
icon: _isLoadingPorts
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.usb),
label: Text(context.l10n.scanner_scan),
),
],
),
),
);
},
);
}
Widget _buildTransportLinks(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Wrap(
spacing: 12,
runSpacing: 8,
children: [
if (!PlatformInfo.isWeb)
OutlinedButton.icon(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const TcpScreen()),
);
},
icon: const Icon(Icons.lan),
label: Text(context.l10n.connectionChoiceTcpLabel),
),
OutlinedButton.icon(
onPressed: () => Navigator.of(context).maybePop(),
icon: const Icon(Icons.bluetooth),
label: Text(context.l10n.connectionChoiceBluetoothLabel),
),
],
),
);
}
@@ -186,7 +166,7 @@ class _UsbScreenState extends State<UsbScreen> {
if (_isLoadingPorts) {
statusText = l10n.usbStatus_searching;
statusColor = Colors.blue;
statusColor = Theme.of(context).colorScheme.primary;
} else if (connector.isUsbTransportConnected) {
switch (connector.state) {
case MeshCoreConnectionState.connected:
@@ -199,7 +179,7 @@ class _UsbScreenState extends State<UsbScreen> {
statusColor = Colors.orange;
default:
statusText = l10n.usbStatus_notConnected;
statusColor = Colors.grey;
statusColor = Theme.of(context).colorScheme.onSurfaceVariant;
}
} else if (connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.usb) {
@@ -207,7 +187,7 @@ class _UsbScreenState extends State<UsbScreen> {
statusColor = Colors.orange;
} else {
statusText = l10n.usbStatus_notConnected;
statusColor = Colors.grey;
statusColor = Theme.of(context).colorScheme.onSurfaceVariant;
}
return Container(
@@ -219,15 +199,13 @@ class _UsbScreenState extends State<UsbScreen> {
Icon(Icons.circle, size: 12, color: statusColor),
const SizedBox(width: 8),
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
statusText,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w500,
),
child: Text(
statusText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w500,
),
),
),
@@ -244,11 +222,11 @@ class _UsbScreenState extends State<UsbScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.usb, size: 64, color: Colors.grey[400]),
Icon(Icons.usb, size: 64, color: Theme.of(context).colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text(
l10n.usbStatus_searching,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
style: TextStyle(fontSize: 16, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
],
),
@@ -260,12 +238,12 @@ class _UsbScreenState extends State<UsbScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.usb, size: 64, color: Colors.grey[400]),
Icon(Icons.usb, size: 64, color: Theme.of(context).colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text(
l10n.usbScreenEmptyState,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
style: TextStyle(fontSize: 16, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
],
),
@@ -294,10 +272,7 @@ class _UsbScreenState extends State<UsbScreen> {
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: showRawName ? Text(rawName) : null,
trailing: ElevatedButton(
onPressed: isConnecting ? null : () => _connectPort(port),
child: Text(l10n.common_connect),
),
trailing: const Icon(Icons.chevron_right),
onTap: isConnecting ? null : () => _connectPort(port),
);
},
@@ -387,7 +362,7 @@ class _UsbScreenState extends State<UsbScreen> {
showDismissibleSnackBar(
context,
content: Text(_friendlyErrorMessage(error)),
backgroundColor: Colors.red,
backgroundColor: Theme.of(context).colorScheme.error,
);
}