mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-11 11:07:04 +10:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
+198
-833
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user