mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-12 20:02:01 +10:00
Merge branch 'dev' into update-pacox-multibyte
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,9 @@ class AppDebugLogScreen extends StatelessWidget {
|
||||
entry.formattedTime,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey[600],
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -88,14 +90,16 @@ 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 +107,9 @@ class AppDebugLogScreen extends StatelessWidget {
|
||||
context.l10n.debugLog_enableInSettings,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -115,18 +121,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,118 +349,154 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
if (settingsService.settings.autoRouteRotationEnabled) ...[
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_maxRouteWeight),
|
||||
subtitle: Column(
|
||||
if (settingsService.settings.autoRouteRotationEnabled)
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.appSettings_maxRouteWeightSubtitle),
|
||||
Slider(
|
||||
value: settingsService.settings.maxRouteWeight,
|
||||
min: 1,
|
||||
max: 10,
|
||||
divisions: 9,
|
||||
label: settingsService.settings.maxRouteWeight
|
||||
.round()
|
||||
.toString(),
|
||||
onChanged: (value) =>
|
||||
settingsService.setMaxRouteWeight(value),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_maxRouteWeight),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.appSettings_maxRouteWeightSubtitle),
|
||||
Slider(
|
||||
value: settingsService.settings.maxRouteWeight,
|
||||
min: 1,
|
||||
max: 10,
|
||||
divisions: 9,
|
||||
label: settingsService.settings.maxRouteWeight
|
||||
.round()
|
||||
.toString(),
|
||||
onChanged: (value) =>
|
||||
settingsService.setMaxRouteWeight(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_initialRouteWeight),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.appSettings_initialRouteWeightSubtitle,
|
||||
),
|
||||
Slider(
|
||||
value: settingsService.settings.initialRouteWeight,
|
||||
min: 0.5,
|
||||
max: 5.0,
|
||||
divisions: 9,
|
||||
label: settingsService.settings.initialRouteWeight
|
||||
.toStringAsFixed(1),
|
||||
onChanged: (value) =>
|
||||
settingsService.setInitialRouteWeight(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
title: Text(
|
||||
context.l10n.appSettings_routeWeightSuccessIncrement,
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context
|
||||
.l10n
|
||||
.appSettings_routeWeightSuccessIncrementSubtitle,
|
||||
),
|
||||
Slider(
|
||||
value: settingsService
|
||||
.settings
|
||||
.routeWeightSuccessIncrement,
|
||||
min: 0.1,
|
||||
max: 2.0,
|
||||
divisions: 19,
|
||||
label: settingsService
|
||||
.settings
|
||||
.routeWeightSuccessIncrement
|
||||
.toStringAsFixed(1),
|
||||
onChanged: (value) => settingsService
|
||||
.setRouteWeightSuccessIncrement(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
title: Text(
|
||||
context.l10n.appSettings_routeWeightFailureDecrement,
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context
|
||||
.l10n
|
||||
.appSettings_routeWeightFailureDecrementSubtitle,
|
||||
),
|
||||
Slider(
|
||||
value: settingsService
|
||||
.settings
|
||||
.routeWeightFailureDecrement,
|
||||
min: 0.1,
|
||||
max: 2.0,
|
||||
divisions: 19,
|
||||
label: settingsService
|
||||
.settings
|
||||
.routeWeightFailureDecrement
|
||||
.toStringAsFixed(1),
|
||||
onChanged: (value) => settingsService
|
||||
.setRouteWeightFailureDecrement(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_maxMessageRetries),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.appSettings_maxMessageRetriesSubtitle,
|
||||
),
|
||||
Slider(
|
||||
value: settingsService.settings.maxMessageRetries
|
||||
.toDouble(),
|
||||
min: 2,
|
||||
max: 10,
|
||||
divisions: 8,
|
||||
label: settingsService.settings.maxMessageRetries
|
||||
.toString(),
|
||||
onChanged: (value) => settingsService
|
||||
.setMaxMessageRetries(value.toInt()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_initialRouteWeight),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.appSettings_initialRouteWeightSubtitle),
|
||||
Slider(
|
||||
value: settingsService.settings.initialRouteWeight,
|
||||
min: 0.5,
|
||||
max: 5.0,
|
||||
divisions: 9,
|
||||
label: settingsService.settings.initialRouteWeight
|
||||
.toStringAsFixed(1),
|
||||
onChanged: (value) =>
|
||||
settingsService.setInitialRouteWeight(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.location_searching),
|
||||
title: Text(context.l10n.appSettings_enableMessageTracing),
|
||||
subtitle: Text(
|
||||
context.l10n.appSettings_enableMessageTracingSubtitle,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_routeWeightSuccessIncrement),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context
|
||||
.l10n
|
||||
.appSettings_routeWeightSuccessIncrementSubtitle,
|
||||
),
|
||||
Slider(
|
||||
value: settingsService.settings.routeWeightSuccessIncrement,
|
||||
min: 0.1,
|
||||
max: 2.0,
|
||||
divisions: 19,
|
||||
label: settingsService.settings.routeWeightSuccessIncrement
|
||||
.toStringAsFixed(1),
|
||||
onChanged: (value) =>
|
||||
settingsService.setRouteWeightSuccessIncrement(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_routeWeightFailureDecrement),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context
|
||||
.l10n
|
||||
.appSettings_routeWeightFailureDecrementSubtitle,
|
||||
),
|
||||
Slider(
|
||||
value: settingsService.settings.routeWeightFailureDecrement,
|
||||
min: 0.1,
|
||||
max: 2.0,
|
||||
divisions: 19,
|
||||
label: settingsService.settings.routeWeightFailureDecrement
|
||||
.toStringAsFixed(1),
|
||||
onChanged: (value) =>
|
||||
settingsService.setRouteWeightFailureDecrement(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_maxMessageRetries),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.appSettings_maxMessageRetriesSubtitle),
|
||||
Slider(
|
||||
value: settingsService.settings.maxMessageRetries
|
||||
.toDouble(),
|
||||
min: 2,
|
||||
max: 10,
|
||||
divisions: 8,
|
||||
label: settingsService.settings.maxMessageRetries
|
||||
.toString(),
|
||||
onChanged: (value) =>
|
||||
settingsService.setMaxMessageRetries(value.toInt()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
value: settingsService.settings.enableMessageTracing,
|
||||
onChanged: (value) {
|
||||
settingsService.setEnableMessageTracing(value);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -584,15 +626,25 @@ 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 +655,25 @@ 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 +933,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';
|
||||
@@ -26,8 +26,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';
|
||||
@@ -284,6 +285,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
)
|
||||
: widget.channel.name,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
@@ -312,9 +315,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) => [
|
||||
@@ -322,11 +323,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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -345,34 +352,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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -382,6 +372,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) {
|
||||
@@ -417,14 +426,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
|
||||
@@ -499,181 +514,126 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
const maxSwipeOffset = 64.0;
|
||||
const replySwipeThreshold = 64.0;
|
||||
const bodyFontSize = 14.0;
|
||||
final messageBody = Column(
|
||||
crossAxisAlignment: isOutgoing
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: isOutgoing
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOutgoing) ...[
|
||||
_buildAvatar(message.senderName),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
child: GestureDetector(
|
||||
onTap: PlatformInfo.isDesktop
|
||||
? null
|
||||
: () => _showMessagePathInfo(message),
|
||||
onLongPress: () => _showMessageActions(message),
|
||||
onSecondaryTapUp: PlatformInfo.isDesktop
|
||||
? (_) => _showMessageActions(message)
|
||||
: null,
|
||||
child: Container(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.all(4)
|
||||
: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.65,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isOutgoing
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOutgoing) ...[
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.only(
|
||||
left: 8,
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
)
|
||||
: EdgeInsets.zero,
|
||||
child: Text(
|
||||
message.senderName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
final messageBody = LayoutBuilder(
|
||||
builder: (context, constraints) => Column(
|
||||
crossAxisAlignment: isOutgoing
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: isOutgoing
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOutgoing) ...[
|
||||
_buildAvatar(message.senderName),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
child: GestureDetector(
|
||||
onLongPress: () => _showMessageActions(message),
|
||||
onSecondaryTapUp: PlatformInfo.isDesktop
|
||||
? (_) => _showMessageActions(message)
|
||||
: null,
|
||||
child: Container(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.all(4)
|
||||
: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth * 0.65,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isOutgoing
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOutgoing) ...[
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.only(
|
||||
left: 8,
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
)
|
||||
: EdgeInsets.zero,
|
||||
child: Text(
|
||||
message.senderName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (gifId == null) const SizedBox(height: 4),
|
||||
],
|
||||
if (message.replyToMessageId != null) ...[
|
||||
_buildReplyPreview(message, textScale),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (poi != null)
|
||||
_buildPoiMessage(
|
||||
context,
|
||||
poi,
|
||||
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(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: GifMessage(
|
||||
url:
|
||||
'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: Colors.transparent,
|
||||
fallbackTextColor: isOutgoing
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer
|
||||
.withValues(alpha: 0.7)
|
||||
: Theme.of(context).colorScheme.onSurface
|
||||
.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
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(
|
||||
child: TranslatedMessageContent(
|
||||
displayText: translatedDisplayText,
|
||||
originalText: originalDisplayText,
|
||||
style: TextStyle(
|
||||
fontSize: bodyFontSize * textScale,
|
||||
),
|
||||
originalStyle: TextStyle(
|
||||
fontSize: bodyFontSize * textScale,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Theme.of(context).colorScheme.onSurface
|
||||
.withValues(alpha: 0.72),
|
||||
),
|
||||
),
|
||||
),
|
||||
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 (gifId == null) const SizedBox(height: 4),
|
||||
],
|
||||
if (message.replyToMessageId != null) ...[
|
||||
_buildReplyPreview(message, textScale),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (poi != null)
|
||||
_buildPoiMessage(
|
||||
context,
|
||||
poi,
|
||||
isOutgoing,
|
||||
textScale,
|
||||
message.senderName,
|
||||
)
|
||||
else if (gifId != null)
|
||||
Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: GifMessage(
|
||||
url:
|
||||
'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: Colors.transparent,
|
||||
fallbackTextColor: isOutgoing
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer
|
||||
.withValues(alpha: 0.7)
|
||||
: Theme.of(context).colorScheme.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (enableTracing) ...[
|
||||
if (displayPath.isNotEmpty) ...[
|
||||
)
|
||||
else
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(
|
||||
child: TranslatedMessageContent(
|
||||
displayText: translatedDisplayText,
|
||||
originalText: originalDisplayText,
|
||||
style: TextStyle(
|
||||
fontSize: bodyFontSize * textScale,
|
||||
),
|
||||
originalStyle: TextStyle(
|
||||
fontSize: bodyFontSize * textScale,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.72),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (enableTracing && displayPath.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
@@ -687,8 +647,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
fontSize: 11 * textScale,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -708,62 +670,69 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
Text(
|
||||
_formatTime(context, message.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
fontSize: 11 * textScale,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (message.repeatCount > 0) ...[
|
||||
if (enableTracing && message.repeatCount > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
Icons.repeat,
|
||||
size: 12,
|
||||
color: Colors.grey[600],
|
||||
size: 12 * textScale,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'${message.repeatCount}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
fontSize: 11 * textScale,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
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:
|
||||
MessageStatusIcon(
|
||||
isAcked:
|
||||
message.status ==
|
||||
ChannelMessageStatus.failed
|
||||
? Colors.red
|
||||
: Colors.grey[600],
|
||||
ChannelMessageStatus.sent,
|
||||
isRepeated:
|
||||
message.status ==
|
||||
ChannelMessageStatus.sent &&
|
||||
displayPath.isNotEmpty,
|
||||
isPending:
|
||||
message.status ==
|
||||
ChannelMessageStatus.pending,
|
||||
isFailed:
|
||||
message.status ==
|
||||
ChannelMessageStatus.failed,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (message.reactions.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
|
||||
child: _buildReactionsDisplay(message),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (message.reactions.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
|
||||
child: _buildReactionsDisplay(message),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (!isOutgoing && !PlatformInfo.isDesktop) {
|
||||
@@ -965,9 +934,11 @@ 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}',
|
||||
@@ -1027,8 +998,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,
|
||||
@@ -1044,36 +1015,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(
|
||||
@@ -1123,8 +1064,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),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1419,15 +1359,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(
|
||||
@@ -1470,19 +1409,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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1523,6 +1464,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;
|
||||
@@ -1565,6 +1534,7 @@ class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> {
|
||||
double _maxSwipeDistance = 0;
|
||||
int? _swipePointerId;
|
||||
bool _swipeLockedToHorizontal = false;
|
||||
bool _isRtl = false;
|
||||
|
||||
void _handleSwipeStart(Offset position) {
|
||||
_swipeStartPosition = position;
|
||||
@@ -1585,11 +1555,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;
|
||||
@@ -1601,28 +1573,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();
|
||||
@@ -1662,6 +1638,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,
|
||||
@@ -1675,7 +1655,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(
|
||||
|
||||
@@ -128,7 +128,9 @@ 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),
|
||||
@@ -157,22 +159,33 @@ 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(effectiveHopCount, l10n),
|
||||
),
|
||||
if (observedLabel != null)
|
||||
_buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
|
||||
_buildDetailRow(
|
||||
context,
|
||||
l10n.channelPath_observedLabel,
|
||||
observedLabel,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -280,7 +293,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
return l10n.channelPath_observedSomeOf(observedCount, targetHopCount);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
Widget _buildDetailRow(BuildContext context, String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
@@ -288,7 +301,12 @@ 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)),
|
||||
],
|
||||
@@ -442,17 +460,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,
|
||||
@@ -630,7 +648,9 @@ 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(
|
||||
@@ -703,7 +723,10 @@ class _ChannelMessagePathMapScreenState
|
||||
label,
|
||||
_formatPathPrefixes(selectedPath.pathBytes, width),
|
||||
),
|
||||
style: TextStyle(color: Colors.grey[700], fontSize: 12),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -724,28 +747,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -768,28 +795,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -843,6 +874,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,
|
||||
@@ -865,9 +902,25 @@ 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,21 @@ 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(
|
||||
context.l10n.channels_noChannelsFound,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
? 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
)
|
||||
: (viewState.channelsSortOption ==
|
||||
ChannelSortOption.manual &&
|
||||
@@ -355,6 +346,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;
|
||||
@@ -447,37 +441,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,
|
||||
@@ -538,10 +540,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);
|
||||
@@ -723,23 +730,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,
|
||||
@@ -927,7 +950,11 @@ 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,
|
||||
@@ -1041,7 +1068,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,
|
||||
),
|
||||
),
|
||||
@@ -1212,6 +1241,8 @@ 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,
|
||||
@@ -1236,7 +1267,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
final psk = community
|
||||
.deriveCommunityPublicPsk();
|
||||
final channelName =
|
||||
'${community.name} Public';
|
||||
'${community.name} $publicLabel';
|
||||
connector.setChannel(
|
||||
nextIndex,
|
||||
channelName,
|
||||
@@ -1592,7 +1623,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),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1602,7 +1633,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),
|
||||
@@ -1651,14 +1682,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),
|
||||
@@ -1666,7 +1702,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,
|
||||
),
|
||||
@@ -1690,10 +1729,14 @@ 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>(
|
||||
@@ -1720,14 +1763,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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1828,7 +1877,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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
+240
-882
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,25 @@ 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));
|
||||
}
|
||||
|
||||
|
||||
+231
-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,14 @@ 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 +845,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 +880,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 +920,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -1048,7 +1141,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),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1362,14 +1455,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(
|
||||
@@ -1406,10 +1491,13 @@ 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);
|
||||
@@ -1454,7 +1542,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),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1486,25 +1574,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.
|
||||
@@ -1515,7 +1592,7 @@ class _ContactTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 120,
|
||||
width: 96,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
@@ -1529,7 +1606,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,
|
||||
@@ -1542,7 +1622,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),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1561,37 +1643,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,16 @@ 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 +225,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 +244,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,36 +443,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();
|
||||
final diff = now.difference(lastSeen);
|
||||
|
||||
@@ -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,9 @@ 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 +519,21 @@ 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 +636,10 @@ 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 +708,9 @@ 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 +729,17 @@ 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 +747,9 @@ 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 +757,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 +773,10 @@ 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 +1756,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,9 @@ 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
+221
-170
@@ -185,17 +185,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),
|
||||
),
|
||||
],
|
||||
@@ -420,61 +420,79 @@ 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),
|
||||
],
|
||||
@@ -907,8 +925,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)
|
||||
@@ -920,26 +938,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1041,39 +1061,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1219,7 +1237,9 @@ class _MapScreenState extends State<MapScreen> {
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Text(
|
||||
": $nodeCount",
|
||||
@@ -1232,10 +1252,12 @@ 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}",
|
||||
@@ -1248,10 +1270,12 @@ 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",
|
||||
@@ -1547,56 +1571,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);
|
||||
@@ -1604,7 +1586,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
final unread = connector.getUnreadCountForContactKey(
|
||||
contact.publicKeyHex,
|
||||
);
|
||||
Navigator.pop(dialogContext);
|
||||
Navigator.pop(sheetContext);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -1617,30 +1599,93 @@ 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: SingleChildScrollView(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1725,6 +1770,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);
|
||||
@@ -1756,7 +1804,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
@@ -1821,10 +1869,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
);
|
||||
await connector.refreshDeviceInfo();
|
||||
if (!mounted) return;
|
||||
showDismissibleSnackBar(
|
||||
messenger.context,
|
||||
content: Text(successMsg),
|
||||
);
|
||||
messenger.showSnackBar(SnackBar(content: Text(successMsg)));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
@@ -2213,7 +2258,10 @@ 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),
|
||||
@@ -2390,7 +2438,10 @@ 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(
|
||||
PathHelper.splitPathBytes(
|
||||
|
||||
@@ -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,12 +326,7 @@ 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 &&
|
||||
!(_parsedNeighbors == null || _parsedNeighbors!.isEmpty))
|
||||
@@ -435,7 +376,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
|
||||
fmtDuration(entry.value['lastHeard'] + 0.0),
|
||||
),
|
||||
entry.value['snr'],
|
||||
connector.currentSf!,
|
||||
connector.currentSf,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -447,7 +388,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
|
||||
String label,
|
||||
String value,
|
||||
double snr,
|
||||
int spreadingFactor,
|
||||
int? spreadingFactor,
|
||||
) {
|
||||
final snrUi = snrUiFromSNR(snr, spreadingFactor);
|
||||
return Padding(
|
||||
|
||||
+153
-78
@@ -119,6 +119,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
bool _showNodeLabels = true;
|
||||
Contact? _targetContact;
|
||||
Uint8List? _sentTagBytes;
|
||||
// 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, [int? hashByteWidth]) {
|
||||
return PathHelper.splitPathBytes(
|
||||
@@ -249,17 +252,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,
|
||||
),
|
||||
],
|
||||
@@ -326,6 +329,23 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
return Uint8List.fromList(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(() {
|
||||
@@ -336,9 +356,12 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final traceHashByteWidth = _traceHashByteWidth(widget.pathHashByteWidth);
|
||||
final livePath = _resolveLivePath(connector);
|
||||
_tracedPath = livePath;
|
||||
|
||||
final pathTmp = widget.reversePathAround
|
||||
? _reversePathByHop(widget.path, widget.pathHashByteWidth)
|
||||
: widget.path;
|
||||
? _reversePathByHop(livePath, widget.pathHashByteWidth)
|
||||
: livePath;
|
||||
|
||||
final path = widget.flipPathAround
|
||||
? buildPath(pathTmp, traceHashByteWidth, connector)
|
||||
@@ -461,8 +484,10 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
}
|
||||
final pathBytes = buffer.readBytes(pathLength);
|
||||
final pathData = PathHelper.splitPathBytes(pathBytes, width);
|
||||
// Firmware emits (path_len >> path_sz) hop SNRs plus 1 final SNR (to this node).
|
||||
final snrCount = (pathLength ~/ width) + 1;
|
||||
List<double> snrData = buffer
|
||||
.readRemainingBytes()
|
||||
.readBytes(snrCount)
|
||||
.map((snr) => snr.toSigned(8).toDouble() / 4)
|
||||
.toList();
|
||||
|
||||
@@ -559,13 +584,20 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
final tc = _targetContact!;
|
||||
if (tc.hasLocation) {
|
||||
targetPos = LatLng(tc.latitude!, tc.longitude!);
|
||||
} else if (pathData.length > 1) {
|
||||
} else if (pathData.length > 1 || _tracedPath.isNotEmpty) {
|
||||
// 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 tracedHops = PathHelper.splitPathBytes(
|
||||
_tracedPath,
|
||||
widget.pathHashByteWidth,
|
||||
);
|
||||
final hopsForEndpoint = tracedHops.isNotEmpty
|
||||
? tracedHops
|
||||
: pathData;
|
||||
final lastHop = widget.reversePathAround
|
||||
? pathData.first
|
||||
: pathData.last;
|
||||
? hopsForEndpoint.first
|
||||
: hopsForEndpoint.last;
|
||||
final lastHopKey = _hopKey(lastHop);
|
||||
|
||||
final peers = connector.allContacts
|
||||
@@ -743,7 +775,9 @@ 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(
|
||||
@@ -795,31 +829,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 ? shortLabel : '~$shortLabel',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
hasGps ? shortLabel : '~$shortLabel',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -839,29 +877,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -885,26 +927,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),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1077,6 +1123,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,
|
||||
@@ -1099,9 +1151,32 @@ 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,26 @@ 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,15 @@ 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 +94,18 @@ 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 +115,18 @@ 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 +207,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 +227,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 +245,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 +265,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 +284,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 +343,18 @@ 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,9 @@ 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 +671,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 +772,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 +950,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 +963,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
content: Text(
|
||||
l10n.repeater_settingsPartialFailure(failures.join('; ')),
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -973,7 +978,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 +989,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 +1083,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 +1122,8 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
const SizedBox(height: 16),
|
||||
_buildAdvancedCard(),
|
||||
const SizedBox(height: 32),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
_buildDangerZoneCard(),
|
||||
],
|
||||
),
|
||||
@@ -1388,13 +1339,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 +1375,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 +2202,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 +2231,9 @@ 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,26 @@ 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 +177,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 +236,42 @@ 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 +285,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 +316,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;
|
||||
@@ -316,18 +317,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),
|
||||
@@ -370,13 +359,16 @@ 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(
|
||||
@@ -394,7 +386,10 @@ 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),
|
||||
@@ -662,6 +657,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
int.tryParse(customVars["gps_interval"] ?? "") ?? 900;
|
||||
intervalController.text = currentInterval.toString();
|
||||
|
||||
String? intervalError;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
@@ -697,9 +694,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,
|
||||
@@ -730,24 +733,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;
|
||||
@@ -813,6 +817,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(
|
||||
@@ -832,7 +866,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),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1223,6 +1257,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);
|
||||
@@ -1489,7 +1525,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) {
|
||||
@@ -1601,6 +1654,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
|
||||
content: Text(l10n.settings_error(e.toString())),
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
@@ -1676,6 +1730,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,
|
||||
@@ -1759,6 +1814,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,
|
||||
),
|
||||
@@ -1780,7 +1836,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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
+37
-48
@@ -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,16 +220,11 @@ 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 +263,7 @@ class _TcpScreenState extends State<TcpScreen> {
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.red,
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+620
-119
@@ -1,21 +1,24 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/path_selection.dart';
|
||||
import '../models/app_settings.dart';
|
||||
import '../storage/prefs_manager.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
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';
|
||||
import '../widgets/sync_progress_overlay.dart';
|
||||
import '../widgets/telemetry_location_map.dart';
|
||||
|
||||
class TelemetryScreen extends StatefulWidget {
|
||||
final Contact contact;
|
||||
@@ -27,6 +30,13 @@ class TelemetryScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
static const int _autoRefreshDefaultIntervalSeconds = 20;
|
||||
static const int _autoRefreshDefaultQuantity = 10;
|
||||
static const int _autoRefreshMinIntervalSeconds = 10;
|
||||
static const int _autoRefreshMaxIntervalSeconds = 300;
|
||||
static const int _autoRefreshMinQuantity = 1;
|
||||
static const int _autoRefreshMaxQuantity = 10;
|
||||
|
||||
int _tagData = 0;
|
||||
|
||||
bool _isLoading = false;
|
||||
@@ -37,6 +47,17 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
RepeaterCommandService? _commandService;
|
||||
PathSelection? _pendingStatusSelection;
|
||||
List<Map<String, dynamic>>? _parsedTelemetry;
|
||||
final TextEditingController _autoRefreshIntervalController =
|
||||
TextEditingController(text: '$_autoRefreshDefaultIntervalSeconds');
|
||||
final TextEditingController _autoRefreshQuantityController =
|
||||
TextEditingController(text: '$_autoRefreshDefaultQuantity');
|
||||
Timer? _autoRefreshTimer;
|
||||
bool _isAutoRefreshEnabled = false;
|
||||
bool _activeTelemetryRequestIsAutoRefresh = false;
|
||||
bool _autoRefreshLastAttemptFailed = false;
|
||||
int _autoRefreshCurrentAttempt = 0;
|
||||
int _autoRefreshTotalAttempts = 0;
|
||||
int _autoRefreshIntervalSeconds = _autoRefreshDefaultIntervalSeconds;
|
||||
|
||||
int _tripTime = 0;
|
||||
|
||||
@@ -63,6 +84,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
super.initState();
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
_commandService = RepeaterCommandService(connector);
|
||||
_loadAutoRefreshSettings();
|
||||
_setupMessageListener();
|
||||
_loadTelemetry();
|
||||
_hasData = false;
|
||||
@@ -82,17 +104,26 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
_tagData = reader.readUInt32LE();
|
||||
_tripTime = reader.readUInt32LE();
|
||||
_statusTimeout?.cancel();
|
||||
final isAutoRefreshRequest = _activeTelemetryRequestIsAutoRefresh;
|
||||
_statusTimeout = Timer(Duration(milliseconds: _tripTime), () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isLoaded = false;
|
||||
if (isAutoRefreshRequest && _isAutoRefreshEnabled) {
|
||||
_autoRefreshLastAttemptFailed = true;
|
||||
}
|
||||
});
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(context.l10n.telemetry_requestTimeout),
|
||||
backgroundColor: Colors.red,
|
||||
);
|
||||
if (!isAutoRefreshRequest) {
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(context.l10n.telemetry_requestTimeout),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
);
|
||||
}
|
||||
if (isAutoRefreshRequest && _isAutoRefreshEnabled) {
|
||||
_scheduleNextAutoRefreshAttempt();
|
||||
}
|
||||
_recordTelemetryResult(false);
|
||||
});
|
||||
}
|
||||
@@ -134,15 +165,21 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
final isAutoRefreshRequest = _activeTelemetryRequestIsAutoRefresh;
|
||||
setState(() {
|
||||
_parsedTelemetry = parsedTelemetry;
|
||||
if (isAutoRefreshRequest) {
|
||||
_autoRefreshLastAttemptFailed = false;
|
||||
}
|
||||
_activeTelemetryRequestIsAutoRefresh = false;
|
||||
});
|
||||
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(context.l10n.telemetry_receivedData),
|
||||
backgroundColor: Colors.green,
|
||||
);
|
||||
if (!isAutoRefreshRequest) {
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(context.l10n.telemetry_receivedData),
|
||||
);
|
||||
}
|
||||
_statusTimeout?.cancel();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
@@ -150,14 +187,18 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
_isLoaded = true;
|
||||
_hasData = true;
|
||||
});
|
||||
if (isAutoRefreshRequest) {
|
||||
_scheduleNextAutoRefreshAttempt();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadTelemetry() async {
|
||||
Future<void> _loadTelemetry({bool isAutoRefresh = false}) async {
|
||||
if (_commandService == null) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_isLoaded = false;
|
||||
_activeTelemetryRequestIsAutoRefresh = isAutoRefresh;
|
||||
});
|
||||
try {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
@@ -169,7 +210,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
if (widget.contact.type != advTypeChat) {
|
||||
frame = buildSendBinaryReq(
|
||||
widget.contact.publicKey,
|
||||
payload: Uint8List.fromList([reqTypeGetTelemetry]),
|
||||
payload: buildTelemetryBinaryPayload(),
|
||||
);
|
||||
} else {
|
||||
frame = buildSendTelemetryReq(widget.contact.publicKey);
|
||||
@@ -180,17 +221,76 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isLoaded = false;
|
||||
if (isAutoRefresh) {
|
||||
_autoRefreshLastAttemptFailed = true;
|
||||
}
|
||||
_activeTelemetryRequestIsAutoRefresh = false;
|
||||
});
|
||||
if (isAutoRefresh) {
|
||||
_scheduleNextAutoRefreshAttempt();
|
||||
}
|
||||
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(context.l10n.telemetry_errorLoading(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
);
|
||||
if (!isAutoRefresh) {
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(context.l10n.telemetry_errorLoading(e.toString())),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _loadAutoRefreshSettings() {
|
||||
final prefs = PrefsManager.instance;
|
||||
final contactKey = widget.contact.publicKeyHex;
|
||||
final interval =
|
||||
(prefs.getInt(_autoRefreshIntervalKey(contactKey)) ??
|
||||
_autoRefreshDefaultIntervalSeconds)
|
||||
.clamp(
|
||||
_autoRefreshMinIntervalSeconds,
|
||||
_autoRefreshMaxIntervalSeconds,
|
||||
)
|
||||
.toInt();
|
||||
final quantity =
|
||||
(prefs.getInt(_autoRefreshQuantityKey(contactKey)) ??
|
||||
_autoRefreshDefaultQuantity)
|
||||
.clamp(_autoRefreshMinQuantity, _autoRefreshMaxQuantity)
|
||||
.toInt();
|
||||
|
||||
_autoRefreshIntervalSeconds = interval;
|
||||
_autoRefreshIntervalController.text = interval.toString();
|
||||
_autoRefreshQuantityController.text = quantity.toString();
|
||||
}
|
||||
|
||||
Future<void> _saveAutoRefreshSettings() async {
|
||||
final contactKey = widget.contact.publicKeyHex;
|
||||
final interval = _clampControllerValue(
|
||||
controller: _autoRefreshIntervalController,
|
||||
min: _autoRefreshMinIntervalSeconds,
|
||||
max: _autoRefreshMaxIntervalSeconds,
|
||||
fallback: _autoRefreshIntervalSeconds,
|
||||
);
|
||||
final quantity = _clampControllerValue(
|
||||
controller: _autoRefreshQuantityController,
|
||||
min: _autoRefreshMinQuantity,
|
||||
max: _autoRefreshMaxQuantity,
|
||||
fallback: _autoRefreshDefaultQuantity,
|
||||
);
|
||||
|
||||
final prefs = PrefsManager.instance;
|
||||
await prefs.setInt(_autoRefreshIntervalKey(contactKey), interval);
|
||||
await prefs.setInt(_autoRefreshQuantityKey(contactKey), quantity);
|
||||
}
|
||||
|
||||
String _autoRefreshIntervalKey(String contactKey) {
|
||||
return 'telemetry_auto_refresh_interval_$contactKey';
|
||||
}
|
||||
|
||||
String _autoRefreshQuantityKey(String contactKey) {
|
||||
return 'telemetry_auto_refresh_quantity_$contactKey';
|
||||
}
|
||||
|
||||
void _recordTelemetryResult(bool success) {
|
||||
final selection = _pendingStatusSelection;
|
||||
if (selection == null) return;
|
||||
@@ -206,9 +306,13 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_saveAutoRefreshSettings());
|
||||
_frameSubscription?.cancel();
|
||||
_commandService?.dispose();
|
||||
_statusTimeout?.cancel();
|
||||
_autoRefreshTimer?.cancel();
|
||||
_autoRefreshIntervalController.dispose();
|
||||
_autoRefreshQuantityController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -218,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(
|
||||
@@ -242,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
|
||||
@@ -315,7 +364,9 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
onPressed: _isLoading ? null : _loadTelemetry,
|
||||
onPressed: (_isLoading || _isAutoRefreshEnabled)
|
||||
? null
|
||||
: () => _loadTelemetry(),
|
||||
tooltip: l10n.repeater_refresh,
|
||||
),
|
||||
],
|
||||
@@ -323,7 +374,8 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _loadTelemetry,
|
||||
onRefresh: () =>
|
||||
_isAutoRefreshEnabled ? Future.value() : _loadTelemetry(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
@@ -333,7 +385,10 @@ 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) &&
|
||||
@@ -346,6 +401,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
entry['channel'],
|
||||
isImperialUnits,
|
||||
),
|
||||
_buildAutoRefreshCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -359,7 +415,6 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
int channel,
|
||||
bool isImperialUnits,
|
||||
) {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -384,60 +439,499 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
),
|
||||
const Divider(),
|
||||
for (final entry in channelData.entries)
|
||||
if (entry.key == 'voltage' && channel == 1)
|
||||
_buildInfoRow(
|
||||
l10n.telemetry_batteryLabel,
|
||||
_batteryText(entry.value),
|
||||
)
|
||||
else if (entry.key == 'voltage')
|
||||
_buildInfoRow(
|
||||
l10n.telemetry_voltageLabel,
|
||||
l10n.telemetry_voltageValue(entry.value.toString()),
|
||||
)
|
||||
else if (entry.key == 'temperature' && channel == 1)
|
||||
_buildInfoRow(
|
||||
l10n.telemetry_mcuTemperatureLabel,
|
||||
_temperatureText(entry.value, isImperialUnits),
|
||||
)
|
||||
else if (entry.key == 'temperature')
|
||||
_buildInfoRow(
|
||||
l10n.telemetry_temperatureLabel,
|
||||
_temperatureText(entry.value, isImperialUnits),
|
||||
)
|
||||
else if (entry.key == 'current' && channel == 1)
|
||||
_buildInfoRow(
|
||||
l10n.telemetry_currentLabel,
|
||||
l10n.telemetry_currentValue(entry.value.toString()),
|
||||
)
|
||||
else
|
||||
_buildInfoRow(entry.key, entry.value.toString()),
|
||||
_buildTelemetryField(entry, channel, isImperialUnits),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTelemetryField(
|
||||
MapEntry<String, dynamic> entry,
|
||||
int channel,
|
||||
bool isImperialUnits,
|
||||
) {
|
||||
if (entry.key == 'gps') {
|
||||
return _buildGpsInfo(entry.value);
|
||||
}
|
||||
|
||||
final display = _formatTelemetryField(
|
||||
entry.key,
|
||||
entry.value,
|
||||
channel,
|
||||
isImperialUnits,
|
||||
);
|
||||
return _buildInfoRow(display.label, display.value);
|
||||
}
|
||||
|
||||
_TelemetryFieldDisplay _formatTelemetryField(
|
||||
String key,
|
||||
dynamic value,
|
||||
int channel,
|
||||
bool isImperialUnits,
|
||||
) {
|
||||
final l10n = context.l10n;
|
||||
final text = _telemetryValueText(value);
|
||||
|
||||
switch (key) {
|
||||
case 'digitalInput':
|
||||
return _TelemetryFieldDisplay(l10n.telemetry_digitalInputLabel, text);
|
||||
case 'digitalOutput':
|
||||
return _TelemetryFieldDisplay(l10n.telemetry_digitalOutputLabel, text);
|
||||
case 'analogInput':
|
||||
return _TelemetryFieldDisplay(
|
||||
l10n.telemetry_analogInputLabel,
|
||||
l10n.telemetry_analogValue(text),
|
||||
);
|
||||
case 'analogOutput':
|
||||
return _TelemetryFieldDisplay(
|
||||
l10n.telemetry_analogOutputLabel,
|
||||
l10n.telemetry_analogValue(text),
|
||||
);
|
||||
case 'generic':
|
||||
return _TelemetryFieldDisplay(l10n.telemetry_genericLabel, text);
|
||||
case 'luminosity':
|
||||
return _TelemetryFieldDisplay(
|
||||
l10n.telemetry_luminosityLabel,
|
||||
l10n.telemetry_luminosityValue(text),
|
||||
);
|
||||
case 'presence':
|
||||
return _TelemetryFieldDisplay(l10n.telemetry_presenceLabel, text);
|
||||
case 'temperature':
|
||||
return _TelemetryFieldDisplay(
|
||||
channel == 1
|
||||
? l10n.telemetry_mcuTemperatureLabel
|
||||
: l10n.telemetry_temperatureLabel,
|
||||
_temperatureText(value, isImperialUnits),
|
||||
);
|
||||
case 'humidity':
|
||||
return _TelemetryFieldDisplay(l10n.telemetry_humidityLabel, text);
|
||||
case 'accelerometer':
|
||||
return _TelemetryFieldDisplay(
|
||||
l10n.telemetry_accelerometerLabel,
|
||||
_telemetryAxisText(value),
|
||||
);
|
||||
case 'pressure':
|
||||
return _TelemetryFieldDisplay(
|
||||
l10n.telemetry_pressureLabel,
|
||||
l10n.telemetry_pressureValue(text),
|
||||
);
|
||||
case 'altitude':
|
||||
return _TelemetryFieldDisplay(
|
||||
l10n.telemetry_altitudeLabel,
|
||||
l10n.telemetry_altitudeValue(text),
|
||||
);
|
||||
case 'voltage':
|
||||
return _TelemetryFieldDisplay(
|
||||
channel == 1
|
||||
? l10n.telemetry_batteryLabel
|
||||
: l10n.telemetry_voltageLabel,
|
||||
channel == 1
|
||||
? _batteryText(value)
|
||||
: l10n.telemetry_voltageValue(text),
|
||||
);
|
||||
case 'current':
|
||||
return _TelemetryFieldDisplay(
|
||||
l10n.telemetry_currentLabel,
|
||||
l10n.telemetry_currentValue(text),
|
||||
);
|
||||
case 'frequency':
|
||||
return _TelemetryFieldDisplay(
|
||||
l10n.telemetry_frequencyLabel,
|
||||
l10n.telemetry_frequencyValue(text),
|
||||
);
|
||||
case 'percentage':
|
||||
return _TelemetryFieldDisplay(
|
||||
l10n.telemetry_percentageLabel,
|
||||
l10n.telemetry_percentageValue(text),
|
||||
);
|
||||
case 'concentration':
|
||||
return _TelemetryFieldDisplay(
|
||||
l10n.telemetry_concentrationLabel,
|
||||
l10n.telemetry_concentrationValue(text),
|
||||
);
|
||||
case 'power':
|
||||
return _TelemetryFieldDisplay(
|
||||
l10n.telemetry_powerLabel,
|
||||
l10n.telemetry_powerValue(text),
|
||||
);
|
||||
case 'distance':
|
||||
return _TelemetryFieldDisplay(
|
||||
l10n.telemetry_distanceLabel,
|
||||
l10n.telemetry_distanceValue(text),
|
||||
);
|
||||
case 'energy':
|
||||
return _TelemetryFieldDisplay(
|
||||
l10n.telemetry_energyLabel,
|
||||
l10n.telemetry_energyValue(text),
|
||||
);
|
||||
case 'direction':
|
||||
return _TelemetryFieldDisplay(
|
||||
l10n.telemetry_directionLabel,
|
||||
l10n.telemetry_directionValue(text),
|
||||
);
|
||||
case 'time':
|
||||
return _TelemetryFieldDisplay(
|
||||
l10n.telemetry_timeLabel,
|
||||
_telemetryTimeText(value),
|
||||
);
|
||||
case 'gyrometer':
|
||||
return _TelemetryFieldDisplay(
|
||||
l10n.telemetry_gyrometerLabel,
|
||||
_telemetryAxisText(value),
|
||||
);
|
||||
case 'colour':
|
||||
return _TelemetryFieldDisplay(
|
||||
l10n.telemetry_colourLabel,
|
||||
_telemetryColorText(value),
|
||||
);
|
||||
case 'switch':
|
||||
return _TelemetryFieldDisplay(l10n.telemetry_switchLabel, text);
|
||||
case 'polyline':
|
||||
return _TelemetryFieldDisplay(
|
||||
l10n.telemetry_polylineLabel,
|
||||
_telemetryMapText(value),
|
||||
);
|
||||
default:
|
||||
return _TelemetryFieldDisplay(key, text);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildAutoRefreshCard() {
|
||||
final l10n = context.l10n;
|
||||
final counterText = _autoRefreshCounterText();
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.autorenew,
|
||||
color: Theme.of(context).textTheme.headlineSmall?.color,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.common_autoRefresh,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
_buildAutoRefreshNumberField(
|
||||
controller: _autoRefreshIntervalController,
|
||||
label: l10n.common_interval,
|
||||
min: _autoRefreshMinIntervalSeconds,
|
||||
max: _autoRefreshMaxIntervalSeconds,
|
||||
fallback: _autoRefreshIntervalSeconds,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildAutoRefreshNumberField(
|
||||
controller: _autoRefreshQuantityController,
|
||||
label: l10n.telemetry_autoFetchQuantity,
|
||||
min: _autoRefreshMinQuantity,
|
||||
max: _autoRefreshMaxQuantity,
|
||||
fallback: _autoRefreshDefaultQuantity,
|
||||
),
|
||||
if (counterText != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
counterText,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: _autoRefreshLastAttemptFailed
|
||||
? Theme.of(context).colorScheme.error
|
||||
: null,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _isLoading && !_isAutoRefreshEnabled
|
||||
? null
|
||||
: _toggleAutoRefresh,
|
||||
child: _isAutoRefreshEnabled
|
||||
? SizedBox(
|
||||
width: double.infinity,
|
||||
height: 20,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Center(child: Text(l10n.common_disable)),
|
||||
Positioned(
|
||||
right: 0,
|
||||
child: SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Text(l10n.common_enable),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAutoRefreshNumberField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required int min,
|
||||
required int max,
|
||||
required int fallback,
|
||||
}) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
enabled: !_isAutoRefreshEnabled,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
onEditingComplete: () {
|
||||
_clampControllerValue(
|
||||
controller: controller,
|
||||
min: min,
|
||||
max: max,
|
||||
fallback: fallback,
|
||||
);
|
||||
unawaited(_saveAutoRefreshSettings());
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
onSubmitted: (_) => unawaited(_saveAutoRefreshSettings()),
|
||||
onTapOutside: (_) {
|
||||
unawaited(_saveAutoRefreshSettings());
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String? _autoRefreshCounterText() {
|
||||
if (!_isAutoRefreshEnabled && _autoRefreshCurrentAttempt == 0) return null;
|
||||
final counter = '$_autoRefreshCurrentAttempt/$_autoRefreshTotalAttempts';
|
||||
if (_autoRefreshLastAttemptFailed) {
|
||||
return '${context.l10n.telemetry_error}: $counter';
|
||||
}
|
||||
return counter;
|
||||
}
|
||||
|
||||
void _toggleAutoRefresh() {
|
||||
if (_isAutoRefreshEnabled) {
|
||||
_stopAutoRefresh();
|
||||
return;
|
||||
}
|
||||
_startAutoRefresh();
|
||||
}
|
||||
|
||||
void _startAutoRefresh() {
|
||||
final interval = _clampControllerValue(
|
||||
controller: _autoRefreshIntervalController,
|
||||
min: _autoRefreshMinIntervalSeconds,
|
||||
max: _autoRefreshMaxIntervalSeconds,
|
||||
fallback: _autoRefreshIntervalSeconds,
|
||||
);
|
||||
final quantity = _clampControllerValue(
|
||||
controller: _autoRefreshQuantityController,
|
||||
min: _autoRefreshMinQuantity,
|
||||
max: _autoRefreshMaxQuantity,
|
||||
fallback: _autoRefreshDefaultQuantity,
|
||||
);
|
||||
unawaited(_saveAutoRefreshSettings());
|
||||
|
||||
setState(() {
|
||||
_isAutoRefreshEnabled = true;
|
||||
_autoRefreshIntervalSeconds = interval;
|
||||
_autoRefreshTotalAttempts = quantity;
|
||||
_autoRefreshCurrentAttempt = 0;
|
||||
_autoRefreshLastAttemptFailed = false;
|
||||
});
|
||||
_runAutoRefreshAttempt();
|
||||
}
|
||||
|
||||
void _stopAutoRefresh() {
|
||||
_autoRefreshTimer?.cancel();
|
||||
_autoRefreshTimer = null;
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isAutoRefreshEnabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _runAutoRefreshAttempt() async {
|
||||
if (!_isAutoRefreshEnabled || !mounted) return;
|
||||
if (_autoRefreshCurrentAttempt >= _autoRefreshTotalAttempts) {
|
||||
_stopAutoRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_autoRefreshCurrentAttempt += 1;
|
||||
});
|
||||
await _loadTelemetry(isAutoRefresh: true);
|
||||
}
|
||||
|
||||
void _scheduleNextAutoRefreshAttempt() {
|
||||
if (!_isAutoRefreshEnabled || !mounted) return;
|
||||
_autoRefreshTimer?.cancel();
|
||||
if (_autoRefreshCurrentAttempt >= _autoRefreshTotalAttempts) {
|
||||
_stopAutoRefresh();
|
||||
return;
|
||||
}
|
||||
// Start the interval only after the current request has finished: after a
|
||||
// telemetry response, timeout, or send error. This keeps slow replies from
|
||||
// shortening the intended pause between requests.
|
||||
_autoRefreshTimer = Timer(
|
||||
Duration(seconds: _autoRefreshIntervalSeconds),
|
||||
_runAutoRefreshAttempt,
|
||||
);
|
||||
}
|
||||
|
||||
int _clampControllerValue({
|
||||
required TextEditingController controller,
|
||||
required int min,
|
||||
required int max,
|
||||
required int fallback,
|
||||
}) {
|
||||
final parsed = int.tryParse(controller.text);
|
||||
final value = (parsed ?? fallback).clamp(min, max).toInt();
|
||||
controller.text = value.toString();
|
||||
controller.selection = TextSelection.collapsed(
|
||||
offset: controller.text.length,
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
Widget _buildGpsInfo(dynamic value) {
|
||||
final latitude = _readGpsValue(value, 'latitude');
|
||||
final longitude = _readGpsValue(value, 'longitude');
|
||||
final altitude = _readGpsValue(value, 'altitude');
|
||||
final isValidPosition = _isValidGpsPosition(latitude, longitude);
|
||||
final gpsText = isValidPosition
|
||||
? [
|
||||
latitude!.toStringAsFixed(5),
|
||||
longitude!.toStringAsFixed(5),
|
||||
if (altitude != null) '${altitude.toStringAsFixed(1)} m',
|
||||
].join(', ')
|
||||
: value.toString();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildInfoRow(context.l10n.telemetry_gpsLabel, gpsText),
|
||||
if (isValidPosition)
|
||||
TelemetryLocationMap(
|
||||
// The map renders only after bounds validation, keeping malformed
|
||||
// Cayenne payloads from creating an invalid FlutterMap center.
|
||||
latitude: latitude!,
|
||||
longitude: longitude!,
|
||||
label: widget.contact.name,
|
||||
contactType: widget.contact.type,
|
||||
contactPublicKeyHex: widget.contact.publicKeyHex,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
double? _readGpsValue(dynamic value, String key) {
|
||||
if (value is! Map) return null;
|
||||
final rawValue = value[key];
|
||||
if (rawValue is num) return rawValue.toDouble();
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _isValidGpsPosition(double? latitude, double? longitude) {
|
||||
if (latitude == null || longitude == null) return false;
|
||||
const double epsilon = 1e-6;
|
||||
return (latitude.abs() > epsilon || longitude.abs() > epsilon) &&
|
||||
latitude >= -90.0 &&
|
||||
latitude <= 90.0 &&
|
||||
longitude >= -180.0 &&
|
||||
longitude <= 180.0;
|
||||
}
|
||||
|
||||
String _telemetryValueText(dynamic value) {
|
||||
if (value == null) return context.l10n.common_notAvailable;
|
||||
if (value is double) {
|
||||
return value.toStringAsFixed(value.truncateToDouble() == value ? 0 : 2);
|
||||
}
|
||||
if (value is num) {
|
||||
return value.toString();
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
String _telemetryAxisText(dynamic value) {
|
||||
if (value is! Map) return _telemetryValueText(value);
|
||||
final x = _telemetryValueText(value['x']);
|
||||
final y = _telemetryValueText(value['y']);
|
||||
final z = _telemetryValueText(value['z']);
|
||||
return 'X: $x, Y: $y, Z: $z';
|
||||
}
|
||||
|
||||
String _telemetryColorText(dynamic value) {
|
||||
if (value is! Map) return _telemetryValueText(value);
|
||||
final red = _telemetryValueText(value['red']);
|
||||
final green = _telemetryValueText(value['green']);
|
||||
final blue = _telemetryValueText(value['blue']);
|
||||
return 'R: $red, G: $green, B: $blue';
|
||||
}
|
||||
|
||||
String _telemetryMapText(dynamic value) {
|
||||
if (value is! Map) return _telemetryValueText(value);
|
||||
return value.entries
|
||||
.map((entry) => '${entry.key}: ${entry.value}')
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
String _telemetryTimeText(dynamic value) {
|
||||
if (value is! num || value <= 0) return _telemetryValueText(value);
|
||||
final dateTime = DateTime.fromMillisecondsSinceEpoch(
|
||||
value.toInt() * 1000,
|
||||
isUtc: true,
|
||||
).toLocal();
|
||||
final localizations = MaterialLocalizations.of(context);
|
||||
final time = localizations.formatTimeOfDay(
|
||||
TimeOfDay.fromDateTime(dateTime),
|
||||
);
|
||||
return '${localizations.formatFullDate(dateTime)} $time';
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -485,3 +979,10 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
return '${tempC.toStringAsFixed(1)}°C';
|
||||
}
|
||||
}
|
||||
|
||||
class _TelemetryFieldDisplay {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _TelemetryFieldDisplay(this.label, this.value);
|
||||
}
|
||||
|
||||
+72
-86
@@ -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,16 +199,11 @@ 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 +219,18 @@ 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 +242,19 @@ 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 +283,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 +373,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