Merge branch 'dev' into update-pacox-multibyte

This commit is contained in:
HDDen
2026-06-12 13:39:09 +03:00
95 changed files with 14166 additions and 6756 deletions
+17 -10
View File
@@ -63,7 +63,7 @@ class AppDebugLogScreen extends StatelessWidget {
final entry = entries[index];
return ListTile(
dense: true,
leading: _buildLevelIcon(entry.level),
leading: _buildLevelIcon(context, entry.level),
title: Text(
'[${entry.tag}] ${entry.message}',
style: const TextStyle(
@@ -75,7 +75,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);
}
}
}
+198 -191
View File
@@ -92,11 +92,29 @@ class AppSettingsScreen extends StatelessWidget {
ListTile(
leading: const Icon(Icons.brightness_6_outlined),
title: Text(context.l10n.appSettings_theme),
subtitle: Text(
_themeModeLabel(context, settingsService.settings.themeMode),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8),
child: SegmentedButton<String>(
segments: [
ButtonSegment(
value: 'system',
label: Text(context.l10n.appSettings_themeSystem),
),
ButtonSegment(
value: 'light',
label: Text(context.l10n.appSettings_themeLight),
),
ButtonSegment(
value: 'dark',
label: Text(context.l10n.appSettings_themeDark),
),
],
selected: {settingsService.settings.themeMode},
onSelectionChanged: (selection) {
settingsService.setThemeMode(selection.first);
},
),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showThemeModeDialog(context, settingsService),
),
const Divider(height: 1),
ListTile(
@@ -111,18 +129,6 @@ class AppSettingsScreen extends StatelessWidget {
trailing: const Icon(Icons.chevron_right),
onTap: () => _showLanguageDialog(context, settingsService),
),
const Divider(height: 1),
SwitchListTile(
secondary: const Icon(Icons.location_searching),
title: Text(context.l10n.appSettings_enableMessageTracing),
subtitle: Text(
context.l10n.appSettings_enableMessageTracingSubtitle,
),
value: settingsService.settings.enableMessageTracing,
onChanged: (value) {
settingsService.setEnableMessageTracing(value);
},
),
],
),
);
@@ -189,14 +195,14 @@ class AppSettingsScreen extends StatelessWidget {
Icons.message_outlined,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
: Theme.of(context).disabledColor,
),
title: Text(
context.l10n.appSettings_messageNotifications,
style: TextStyle(
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
: Theme.of(context).disabledColor,
),
),
subtitle: Text(
@@ -204,7 +210,7 @@ class AppSettingsScreen extends StatelessWidget {
style: TextStyle(
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
: Theme.of(context).disabledColor,
),
),
value: settingsService.settings.notifyOnNewMessage,
@@ -220,14 +226,14 @@ class AppSettingsScreen extends StatelessWidget {
Icons.forum_outlined,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
: Theme.of(context).disabledColor,
),
title: Text(
context.l10n.appSettings_channelMessageNotifications,
style: TextStyle(
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
: Theme.of(context).disabledColor,
),
),
subtitle: Text(
@@ -235,7 +241,7 @@ class AppSettingsScreen extends StatelessWidget {
style: TextStyle(
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
: Theme.of(context).disabledColor,
),
),
value: settingsService.settings.notifyOnNewChannelMessage,
@@ -251,14 +257,14 @@ class AppSettingsScreen extends StatelessWidget {
Icons.cell_tower,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
: Theme.of(context).disabledColor,
),
title: Text(
context.l10n.appSettings_advertisementNotifications,
style: TextStyle(
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
: Theme.of(context).disabledColor,
),
),
subtitle: Text(
@@ -266,7 +272,7 @@ class AppSettingsScreen extends StatelessWidget {
style: TextStyle(
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
: Theme.of(context).disabledColor,
),
),
value: settingsService.settings.notifyOnNewAdvert,
@@ -343,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':
+283 -303
View File
@@ -5,7 +5,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:intl/intl.dart' hide TextDirection;
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
@@ -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(
+108 -55
View File
@@ -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),
+120 -71
View File
@@ -111,24 +111,26 @@ class _ChannelsScreenState extends State<ChannelsScreen>
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.logout, color: Colors.red),
Icon(
Icons.logout,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8),
Text(context.l10n.common_disconnect),
],
),
onTap: () => _disconnect(context),
),
if (_communities.isNotEmpty)
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.groups),
const SizedBox(width: 8),
Text(context.l10n.community_manageCommunities),
],
),
onTap: () => _showManageCommunitiesDialog(context),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.groups),
const SizedBox(width: 8),
Text(context.l10n.community_manageCommunities),
],
),
onTap: () => _showManageCommunitiesDialog(context),
),
PopupMenuItem(
child: Row(
children: [
@@ -241,32 +243,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),
),
),
],
File diff suppressed because it is too large Load Diff
+18 -21
View File
@@ -8,34 +8,25 @@ class ChromeRequiredScreen extends StatelessWidget {
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final colorScheme = theme.colorScheme;
return Scaffold(
body: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 32),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDark
? [const Color(0xFF1A1A1A), const Color(0xFF0D0D0D)]
: [const Color(0xFFF5F7FA), const Color(0xFFE4E7EB)],
),
),
color: colorScheme.surface,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
color: colorScheme.tertiaryContainer.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(
child: Icon(
Icons.browser_not_supported_rounded,
size: 80,
color: Colors.orange,
color: colorScheme.tertiary,
),
),
const SizedBox(height: 32),
@@ -44,7 +35,7 @@ class ChromeRequiredScreen extends StatelessWidget {
textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : Colors.black87,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 16),
@@ -52,7 +43,7 @@ class ChromeRequiredScreen extends StatelessWidget {
l10n.scanner_chromeRequiredMessage,
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
color: isDark ? Colors.white70 : Colors.black54,
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
@@ -62,19 +53,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
View File
@@ -29,6 +29,7 @@ import '../widgets/repeater_login_dialog.dart';
import '../widgets/room_login_dialog.dart';
import '../widgets/sync_progress_overlay.dart';
import '../widgets/unread_badge.dart';
import '../helpers/contact_ui.dart';
import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart';
import 'chat_screen.dart';
@@ -59,7 +60,7 @@ class _ContactsScreenState extends State<ContactsScreen>
String _loadedGroupScopeKeyHex = '';
Timer? _searchDebounce;
final Set<ContactOperationType> _pendingOperations = {};
final List<ContactOperationType> _pendingOperations = [];
StreamSubscription<Uint8List>? _frameSubscription;
@@ -185,59 +186,52 @@ class _ContactsScreenState extends State<ContactsScreen>
Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
}
// Generic OK/ERR acks carry no command correlation, so consume only
// the oldest pending operation per ack instead of clearing all.
if (code == respCodeOk) {
// Show a snackbar indicating success
if (!mounted) return;
if (_pendingOperations.contains(ContactOperationType.import)) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactImported),
);
if (_pendingOperations.isEmpty) return;
final op = _pendingOperations.removeAt(0);
switch (op) {
case ContactOperationType.import:
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactImported),
);
case ContactOperationType.zeroHopShare:
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_zeroHopContactAdvertSent),
);
case ContactOperationType.export:
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactAdvertCopied),
);
}
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_zeroHopContactAdvertSent),
);
}
if (_pendingOperations.contains(ContactOperationType.export)) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactAdvertCopied),
);
}
_pendingOperations.clear();
}
if (code == respCodeErr) {
// Show a snackbar indicating failure
if (!mounted) return;
if (_pendingOperations.contains(ContactOperationType.import)) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactImportFailed),
);
if (_pendingOperations.isEmpty) return;
final op = _pendingOperations.removeAt(0);
switch (op) {
case ContactOperationType.import:
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactImportFailed),
);
case ContactOperationType.zeroHopShare:
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_zeroHopContactAdvertFailed),
);
case ContactOperationType.export:
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactAdvertCopyFailed),
);
}
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_zeroHopContactAdvertFailed),
);
}
if (_pendingOperations.contains(ContactOperationType.export)) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactAdvertCopyFailed),
);
}
_pendingOperations.clear();
}
} catch (e) {
appLogger.error(
@@ -252,17 +246,37 @@ class _ContactsScreenState extends State<ContactsScreen>
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final exportContactFrame = buildExportContactFrame(pubKey);
_pendingOperations.add(ContactOperationType.export);
await connector.sendFrame(exportContactFrame, expectsGenericAck: true);
try {
await connector.sendFrame(exportContactFrame, expectsGenericAck: true);
} catch (e) {
_pendingOperations.remove(ContactOperationType.export);
if (mounted) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactAdvertCopyFailed),
);
}
}
}
Future<void> _contactZeroHop(Uint8List pubKey) async {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final exportContactZeroHopFrame = buildZeroHopContact(pubKey);
_pendingOperations.add(ContactOperationType.zeroHopShare);
await connector.sendFrame(
exportContactZeroHopFrame,
expectsGenericAck: true,
);
try {
await connector.sendFrame(
exportContactZeroHopFrame,
expectsGenericAck: true,
);
} catch (e) {
_pendingOperations.remove(ContactOperationType.zeroHopShare);
if (mounted) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_zeroHopContactAdvertFailed),
);
}
}
}
Future<void> _contactImport() async {
@@ -288,11 +302,10 @@ class _ContactsScreenState extends State<ContactsScreen>
return;
}
final hexString = text.substring('meshcore://'.length);
final Uint8List importContactFrame;
try {
final bytes = hex2Uint8List(hexString);
final importContactFrame = buildImportContactFrame(bytes);
_pendingOperations.add(ContactOperationType.import);
connector.importContact(importContactFrame);
importContactFrame = buildImportContactFrame(bytes);
} catch (e) {
if (mounted) {
showDismissibleSnackBar(
@@ -300,6 +313,19 @@ class _ContactsScreenState extends State<ContactsScreen>
content: Text(context.l10n.contacts_invalidAdvertFormat),
);
}
return;
}
_pendingOperations.add(ContactOperationType.import);
try {
await connector.sendFrame(importContactFrame, expectsGenericAck: true);
} catch (e) {
_pendingOperations.remove(ContactOperationType.import);
if (mounted) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactImportFailed),
);
}
}
}
@@ -322,7 +348,34 @@ class _ContactsScreenState extends State<ContactsScreen>
bottom: const SyncProgressAppBarBottom(),
actions: [
PopupMenuButton(
itemBuilder: (context) => [
tooltip: context.l10n.contacts_moreOptions,
itemBuilder: (context) => <PopupMenuEntry<dynamic>>[
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.person_add_rounded),
const SizedBox(width: 8),
Text(context.l10n.discoveredContacts_Title),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DiscoveryScreen(),
),
),
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.paste),
const SizedBox(width: 8),
Text(context.l10n.contacts_addContactFromClipboard),
],
),
onTap: () => _contactImport(),
),
const PopupMenuDivider(),
PopupMenuItem(
child: Row(
children: [
@@ -365,46 +418,20 @@ class _ContactsScreenState extends State<ContactsScreen>
),
onTap: () => _contactExport(Uint8List.fromList([])),
),
const PopupMenuDivider(),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.paste),
const SizedBox(width: 8),
Text(context.l10n.contacts_addContactFromClipboard),
],
),
onTap: () => _contactImport(),
),
],
icon: const Icon(Icons.connect_without_contact),
),
PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.logout, color: Colors.red),
Icon(
Icons.logout,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8),
Text(context.l10n.common_disconnect),
],
),
onTap: () => _disconnect(context, connector),
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.person_add_rounded),
const SizedBox(width: 8),
Text(context.l10n.discoveredContacts_Title),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DiscoveryScreen(),
),
),
),
PopupMenuItem(
child: Row(
children: [
@@ -426,6 +453,10 @@ class _ContactsScreenState extends State<ContactsScreen>
],
),
body: _buildContactsBody(context, connector),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddContactSheet(context),
child: const Icon(Icons.person_add),
),
bottomNavigationBar: SafeArea(
top: false,
child: QuickSwitchBar(
@@ -440,6 +471,40 @@ class _ContactsScreenState extends State<ContactsScreen>
);
}
void _showAddContactSheet(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.paste),
title: Text(context.l10n.contacts_addContactFromClipboard),
onTap: () {
Navigator.pop(sheetContext);
_contactImport();
},
),
ListTile(
leading: const Icon(Icons.person_add_rounded),
title: Text(context.l10n.discoveredContacts_Title),
onTap: () {
Navigator.pop(sheetContext);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DiscoveryScreen(),
),
);
},
),
],
),
),
);
}
Future<void> _disconnect(
BuildContext context,
MeshCoreConnector connector,
@@ -571,7 +636,11 @@ class _ContactsScreenState extends State<ContactsScreen>
const SizedBox(width: 8),
IconButton(
tooltip: menuContext.l10n.contacts_deleteGroup,
icon: const Icon(Icons.delete, size: 20, color: Colors.red),
icon: Icon(
Icons.delete,
size: 20,
color: Theme.of(context).colorScheme.error,
),
onPressed: canManageGroups
? () => _closeDropdownAndRun(
menuContext,
@@ -589,16 +658,25 @@ class _ContactsScreenState extends State<ContactsScreen>
],
child: SizedBox(
height: 48,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
Expanded(
child: Text(selectedGroupName, overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 8),
const Icon(Icons.arrow_drop_down),
],
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).colorScheme.outline),
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
Expanded(
child: Text(
selectedGroupName,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
const Icon(Icons.arrow_drop_down),
],
),
),
),
),
@@ -624,6 +702,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) {
+28 -44
View File
@@ -12,6 +12,7 @@ import '../utils/contact_search.dart';
import '../utils/platform_info.dart';
import '../widgets/app_bar.dart';
import '../widgets/list_filter_widget.dart';
import '../helpers/contact_ui.dart';
import '../helpers/snack_bar_builder.dart';
enum DiscoverySortOption { lastSeen, name, type }
@@ -71,7 +72,10 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.delete, color: Colors.red),
Icon(
Icons.delete,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8),
Text(context.l10n.discoveredContacts_deleteContactAll),
],
@@ -99,9 +103,9 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
final contact = filteredAndSorted[index];
final tile = ListTile(
leading: CircleAvatar(
backgroundColor: _getTypeColor(contact.type),
backgroundColor: contactTypeColor(contact.type),
child: Icon(
_getTypeIcon(contact.type),
contactTypeIcon(contact.type),
color: Colors.white,
size: 20,
),
@@ -142,7 +146,9 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
Row(
@@ -152,7 +158,10 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
Icon(
Icons.location_on,
size: 14,
color: Colors.grey[400],
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.6),
),
if (contact.rawPacket != null)
const SizedBox(width: 2),
@@ -160,7 +169,10 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
Icon(
Icons.cell_tower,
size: 14,
color: Colors.grey[400],
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.6),
),
],
),
@@ -170,6 +182,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);
+42 -16
View File
@@ -73,7 +73,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
double _startAntennaHeight = 5.0;
double _endAntennaHeight = 5.0;
bool _showHud = true;
bool _menuExpanded = true;
bool _menuExpanded = false;
bool _showDisplayNodes = true;
bool _showMarkerLabels = true;
bool _didReceivePositionUpdate = false;
@@ -159,17 +159,17 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
children: [
IconButton(
icon: const Icon(Icons.add),
tooltip: 'Zoom in',
tooltip: context.l10n.map_zoomIn,
onPressed: () => _zoomMapBy(1),
),
IconButton(
icon: const Icon(Icons.remove),
tooltip: 'Zoom out',
tooltip: context.l10n.map_zoomOut,
onPressed: () => _zoomMapBy(-1),
),
IconButton(
icon: const Icon(Icons.my_location),
tooltip: 'Center map',
tooltip: context.l10n.map_centerMap,
onPressed: () => _resetMapView(
initialCenter: initialCenter,
initialZoom: initialZoom,
@@ -224,6 +224,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
setState(() {
_result = result;
_selectedObstruction = _defaultObstructionFor(result);
_menuExpanded = true;
});
} catch (e) {
if (!mounted) return;
@@ -506,7 +507,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,
);
+6 -4
View File
@@ -83,17 +83,17 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
children: [
IconButton(
icon: const Icon(Icons.add),
tooltip: 'Zoom in',
tooltip: context.l10n.map_zoomIn,
onPressed: () => _zoomMapBy(1),
),
IconButton(
icon: const Icon(Icons.remove),
tooltip: 'Zoom out',
tooltip: context.l10n.map_zoomOut,
onPressed: () => _zoomMapBy(-1),
),
IconButton(
icon: const Icon(Icons.my_location),
tooltip: 'Center map',
tooltip: context.l10n.map_centerMap,
onPressed: _resetMapView,
),
],
@@ -458,7 +458,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
View File
@@ -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(
+15 -74
View File
@@ -9,7 +9,8 @@ import '../models/path_selection.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../services/repeater_command_service.dart';
import '../widgets/path_management_dialog.dart';
import '../widgets/empty_state.dart';
import '../widgets/routing_sheet.dart';
import '../widgets/snr_indicator.dart';
import '../helpers/snack_bar_builder.dart';
@@ -167,7 +168,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
showDismissibleSnackBar(
context,
content: Text(context.l10n.neighbors_receivedData),
backgroundColor: Colors.green,
backgroundColor: Theme.of(context).colorScheme.tertiary,
);
_statusTimeout?.cancel();
if (!mounted) return;
@@ -227,7 +228,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
showDismissibleSnackBar(
context,
content: Text(context.l10n.neighbors_requestTimedOut),
backgroundColor: Colors.red,
backgroundColor: Theme.of(context).colorScheme.error,
);
_recordStatusResult(false);
});
@@ -241,7 +242,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
showDismissibleSnackBar(
context,
content: Text(context.l10n.neighbors_errorLoading(e.toString())),
backgroundColor: Colors.red,
backgroundColor: Theme.of(context).colorScheme.error,
);
}
}
@@ -279,7 +280,9 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
children: [
Text(
l10n.neighbors_repeatersNeighbors,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
repeater.name,
@@ -287,75 +290,18 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
fontSize: 14,
fontWeight: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
centerTitle: false,
actions: [
PopupMenuButton<String>(
IconButton(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
} else {
await connector.setPathOverride(repeater, pathLen: null);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
],
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement,
onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
ContactRoutingSheet.show(context, contact: repeater),
),
IconButton(
icon: _isLoading
@@ -380,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
View File
@@ -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),
+50 -81
View File
@@ -8,7 +8,7 @@ import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../widgets/debug_frame_viewer.dart';
import '../services/repeater_command_service.dart';
import '../widgets/path_management_dialog.dart';
import '../widgets/routing_sheet.dart';
import '../helpers/snack_bar_builder.dart';
class RepeaterCliScreen extends StatefulWidget {
@@ -252,97 +252,29 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.repeater_cliTitle),
Text(
l10n.repeater_cliTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
repeater.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
centerTitle: false,
actions: [
PopupMenuButton<String>(
IconButton(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
} else {
await connector.setPathOverride(repeater, pathLen: null);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
],
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement,
onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
),
IconButton(
icon: const Icon(Icons.bug_report),
tooltip: l10n.repeater_debugNextCommand,
onPressed: () {
// Set a flag or just send next command with debug
if (_commandController.text.trim().isNotEmpty) {
_sendCommand(showDebug: true);
} else {
showDismissibleSnackBar(
context,
content: Text(l10n.repeater_enterCommandFirst),
);
}
},
ContactRoutingSheet.show(context, contact: repeater),
),
IconButton(
icon: const Icon(Icons.help_outline),
@@ -354,6 +286,33 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
tooltip: l10n.repeater_clearHistory,
onPressed: _commandHistory.isEmpty ? null : _clearHistory,
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
if (value == 'debug') {
if (_commandController.text.trim().isNotEmpty) {
_sendCommand(showDebug: true);
} else {
showDismissibleSnackBar(
context,
content: Text(l10n.repeater_enterCommandFirst),
);
}
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'debug',
child: Row(
children: [
const Icon(Icons.bug_report),
const SizedBox(width: 8),
Text(l10n.repeater_debugNextCommand),
],
),
),
],
),
],
),
body: Column(
@@ -426,16 +385,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,
),
),
],
),
+34 -14
View File
@@ -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,
),
],
),
),
+50 -79
View File
@@ -8,7 +8,7 @@ import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../services/repeater_command_service.dart';
import '../services/storage_service.dart';
import '../widgets/path_management_dialog.dart';
import '../widgets/routing_sheet.dart';
import '../helpers/snack_bar_builder.dart';
class RepeaterSettingsScreen extends StatefulWidget {
@@ -126,6 +126,8 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
// Location settings
final TextEditingController _latController = TextEditingController();
final TextEditingController _lonController = TextEditingController();
bool _latInvalid = false;
bool _lonInvalid = false;
// Feature toggles
bool _repeatEnabled = true;
@@ -457,7 +459,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),
),
+19 -73
View File
@@ -11,7 +11,7 @@ import '../connector/meshcore_protocol.dart';
import '../services/app_settings_service.dart';
import '../services/repeater_command_service.dart';
import '../utils/battery_utils.dart';
import '../widgets/path_management_dialog.dart';
import '../widgets/routing_sheet.dart';
import '../helpers/snack_bar_builder.dart';
class RepeaterStatusScreen extends StatefulWidget {
@@ -318,7 +318,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
showDismissibleSnackBar(
context,
content: Text(context.l10n.repeater_statusRequestTimeout),
backgroundColor: Colors.red,
backgroundColor: Theme.of(context).colorScheme.error,
);
_recordStatusResult(false);
});
@@ -331,7 +331,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
showDismissibleSnackBar(
context,
content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())),
backgroundColor: Colors.red,
backgroundColor: Theme.of(context).colorScheme.error,
);
}
_recordStatusResult(false);
@@ -360,82 +360,29 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.repeater_statusTitle),
Text(
l10n.repeater_statusTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
repeater.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
centerTitle: false,
actions: [
PopupMenuButton<String>(
IconButton(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
} else {
await connector.setPathOverride(repeater, pathLen: null);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
],
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement,
onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
ContactRoutingSheet.show(context, contact: repeater),
),
IconButton(
icon: _isLoading
@@ -588,21 +535,20 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 130,
Expanded(
child: Text(
label,
style: TextStyle(
color: Colors.grey[600],
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(fontWeight: FontWeight.w400),
),
const SizedBox(width: 8),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.w400),
textAlign: TextAlign.end,
),
],
),
+90 -89
View File
@@ -10,6 +10,7 @@ import '../services/linux_ble_error_classifier.dart';
import '../utils/app_logger.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/device_tile.dart';
import '../widgets/empty_state.dart';
import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart';
import 'tcp_screen.dart';
@@ -25,6 +26,7 @@ class ScannerScreen extends StatefulWidget {
class _ScannerScreenState extends State<ScannerScreen> {
bool _changedNavigation = false;
String? _connectingDeviceId;
late final MeshCoreConnector _connector;
late final VoidCallback _connectionListener;
BluetoothAdapterState _bluetoothState = BluetoothAdapterState.unknown;
@@ -101,6 +103,32 @@ class _ScannerScreenState extends State<ScannerScreen> {
title: AdaptiveAppBarTitle(context.l10n.scanner_title),
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
if (PlatformInfo.supportsUsbSerial)
IconButton(
icon: const Icon(Icons.usb),
tooltip: context.l10n.connectionChoiceUsbLabel,
onPressed: () {
appLogger.info(
'USB selected, opening UsbScreen',
tag: 'ScannerScreen',
);
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const UsbScreen()));
},
),
if (!PlatformInfo.isWeb)
IconButton(
icon: const Icon(Icons.lan),
tooltip: context.l10n.connectionChoiceTcpLabel,
onPressed: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const TcpScreen()));
},
),
],
),
body: SafeArea(
top: false,
@@ -122,84 +150,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;
});
}
}
}
+96 -35
View File
@@ -53,6 +53,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _loadVersionInfo() async {
final packageInfo = await PackageInfo.fromPlatform();
if (!mounted) return;
setState(() {
_appVersion = packageInfo.version;
});
@@ -213,12 +214,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (percent == null) {
icon = Icons.battery_unknown;
iconColor = Colors.grey;
iconColor = Theme.of(context).colorScheme.onSurfaceVariant;
valueColor = null;
} else if (percent <= 15) {
icon = Icons.battery_alert;
iconColor = Colors.orange;
valueColor = Colors.orange;
iconColor = Theme.of(context).colorScheme.tertiary;
valueColor = Theme.of(context).colorScheme.tertiary;
} else {
icon = Icons.battery_full;
iconColor = null;
@@ -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
View File
@@ -95,12 +95,14 @@ class _TcpScreenState extends State<TcpScreen> {
final isConnecting =
connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.tcp;
final isButtonDisabled =
isConnecting ||
connector.state == MeshCoreConnectionState.scanning;
// A running BLE scan must not block TCP connect: connectTcp() stops
// any active scan before connecting, so the only reason to disable
// the button is a TCP connect already in flight.
final isButtonDisabled = isConnecting;
return Column(
children: [
_buildStatusBar(context, connector),
_buildTransportLinks(context),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
@@ -154,40 +156,32 @@ class _TcpScreenState extends State<TcpScreen> {
},
),
),
bottomNavigationBar: SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (PlatformInfo.supportsUsbSerial)
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const UsbScreen()),
);
},
heroTag: 'tcp_usb_action',
extendedPadding: const EdgeInsets.symmetric(horizontal: 12),
icon: const Icon(Icons.usb),
label: Text(context.l10n.connectionChoiceUsbLabel),
),
if (PlatformInfo.supportsUsbSerial) const SizedBox(width: 12),
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).maybePop();
},
heroTag: 'tcp_ble_action',
extendedPadding: const EdgeInsets.symmetric(horizontal: 12),
icon: const Icon(Icons.bluetooth),
label: Text(context.l10n.connectionChoiceBluetoothLabel),
),
],
);
}
Widget _buildTransportLinks(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Wrap(
spacing: 12,
runSpacing: 8,
children: [
if (PlatformInfo.supportsUsbSerial)
OutlinedButton.icon(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const UsbScreen()),
);
},
icon: const Icon(Icons.usb),
label: Text(context.l10n.connectionChoiceUsbLabel),
),
OutlinedButton.icon(
onPressed: () => Navigator.of(context).maybePop(),
icon: const Icon(Icons.bluetooth),
label: Text(context.l10n.connectionChoiceBluetoothLabel),
),
),
],
),
);
}
@@ -214,7 +208,7 @@ class _TcpScreenState extends State<TcpScreen> {
statusColor = Colors.orange;
} else {
statusText = l10n.tcpStatus_notConnected;
statusColor = Colors.grey;
statusColor = Theme.of(context).colorScheme.onSurfaceVariant;
}
return Container(
@@ -226,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
View File
@@ -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
View File
@@ -12,7 +12,6 @@ import '../utils/usb_port_labels.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart';
import 'scanner_screen.dart';
import 'tcp_screen.dart';
class UsbScreen extends StatefulWidget {
@@ -100,81 +99,62 @@ class _UsbScreenState extends State<UsbScreen> {
return Column(
children: [
_buildStatusBar(context, connector),
_buildTransportLinks(context),
Expanded(child: _buildPortList(context, connector)),
],
);
},
),
),
bottomNavigationBar: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
final isLoading = _isLoadingPorts;
final showBle = true;
final showTcp = !PlatformInfo.isWeb;
return SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
bottomNavigationBar: _supportsHotPlug
? null
: SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (showTcp)
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const TcpScreen()),
);
},
heroTag: 'usb_tcp_action',
extendedPadding: const EdgeInsets.symmetric(
horizontal: 12,
),
icon: const Icon(Icons.lan),
label: Text(context.l10n.connectionChoiceTcpLabel),
),
if (showTcp && showBle) const SizedBox(width: 12),
if (showBle)
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => const ScannerScreen(),
),
);
},
heroTag: 'usb_ble_action',
extendedPadding: const EdgeInsets.symmetric(
horizontal: 12,
),
icon: const Icon(Icons.bluetooth),
label: Text(context.l10n.connectionChoiceBluetoothLabel),
),
if ((showTcp || showBle) && !_supportsHotPlug)
const SizedBox(width: 12),
if (!_supportsHotPlug)
FloatingActionButton.extended(
onPressed: isLoading ? null : _loadPorts,
heroTag: 'usb_refresh_action',
extendedPadding: const EdgeInsets.symmetric(
horizontal: 12,
),
icon: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.usb),
label: Text(context.l10n.scanner_scan),
),
FloatingActionButton.extended(
onPressed: _isLoadingPorts ? null : _loadPorts,
heroTag: 'usb_refresh_action',
icon: _isLoadingPorts
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.usb),
label: Text(context.l10n.scanner_scan),
),
],
),
),
);
},
);
}
Widget _buildTransportLinks(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Wrap(
spacing: 12,
runSpacing: 8,
children: [
if (!PlatformInfo.isWeb)
OutlinedButton.icon(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const TcpScreen()),
);
},
icon: const Icon(Icons.lan),
label: Text(context.l10n.connectionChoiceTcpLabel),
),
OutlinedButton.icon(
onPressed: () => Navigator.of(context).maybePop(),
icon: const Icon(Icons.bluetooth),
label: Text(context.l10n.connectionChoiceBluetoothLabel),
),
],
),
);
}
@@ -186,7 +166,7 @@ class _UsbScreenState extends State<UsbScreen> {
if (_isLoadingPorts) {
statusText = l10n.usbStatus_searching;
statusColor = Colors.blue;
statusColor = Theme.of(context).colorScheme.primary;
} else if (connector.isUsbTransportConnected) {
switch (connector.state) {
case MeshCoreConnectionState.connected:
@@ -199,7 +179,7 @@ class _UsbScreenState extends State<UsbScreen> {
statusColor = Colors.orange;
default:
statusText = l10n.usbStatus_notConnected;
statusColor = Colors.grey;
statusColor = Theme.of(context).colorScheme.onSurfaceVariant;
}
} else if (connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.usb) {
@@ -207,7 +187,7 @@ class _UsbScreenState extends State<UsbScreen> {
statusColor = Colors.orange;
} else {
statusText = l10n.usbStatus_notConnected;
statusColor = Colors.grey;
statusColor = Theme.of(context).colorScheme.onSurfaceVariant;
}
return Container(
@@ -219,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,
);
}