Merge branch 'dev' into update-pacox-multibyte

This commit is contained in:
HDDen
2026-06-14 01:12:49 +03:00
115 changed files with 25324 additions and 12783 deletions
+79 -34
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../services/app_debug_log_service.dart';
import '../theme/mesh_theme.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart';
@@ -58,27 +59,57 @@ class AppDebugLogScreen extends StatelessWidget {
child: hasEntries
? ListView.separated(
itemCount: entries.length,
separatorBuilder: (_, _) => const Divider(height: 1),
separatorBuilder: (_, _) =>
const Divider(height: 1, color: MeshPalette.line),
itemBuilder: (context, index) {
final entry = entries[index];
return ListTile(
dense: true,
leading: _buildLevelIcon(context, entry.level),
title: Text(
'[${entry.tag}] ${entry.message}',
style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
),
return Container(
color: MeshPalette.bg,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
subtitle: Text(
entry.formattedTime,
style: TextStyle(
fontSize: 10,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildLevelIcon(context, entry.level),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
children: [
TextSpan(
text: '[${entry.tag}] ',
style: MeshTheme.mono(
fontSize: 11.5,
color: _levelColor(entry.level),
),
),
TextSpan(
text: entry.message,
style: MeshTheme.mono(
fontSize: 11.5,
color: MeshPalette.ink2,
),
),
],
),
),
const SizedBox(height: 2),
Text(
entry.formattedTime,
style: MeshTheme.mono(
fontSize: 9.5,
color: MeshPalette.ink4,
),
),
],
),
),
],
),
);
},
@@ -87,29 +118,25 @@ class AppDebugLogScreen extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
const Icon(
Icons.bug_report_outlined,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
color: MeshPalette.ink3,
),
const SizedBox(height: 16),
Text(
context.l10n.debugLog_noEntries,
style: TextStyle(
style: const TextStyle(
fontSize: 16,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
color: MeshPalette.ink3,
),
),
const SizedBox(height: 8),
Text(
context.l10n.debugLog_enableInSettings,
style: TextStyle(
style: const TextStyle(
fontSize: 12,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
color: MeshPalette.ink3,
),
),
],
@@ -121,19 +148,37 @@ class AppDebugLogScreen extends StatelessWidget {
);
}
Widget _buildLevelIcon(BuildContext context, AppDebugLogLevel level) {
final colorScheme = Theme.of(context).colorScheme;
Color _levelColor(AppDebugLogLevel level) {
switch (level) {
case AppDebugLogLevel.info:
return Icon(Icons.info_outline, size: 18, color: colorScheme.primary);
return MeshPalette.blue;
case AppDebugLogLevel.warning:
return Icon(
return MeshPalette.warn;
case AppDebugLogLevel.error:
return MeshPalette.alert;
}
}
Widget _buildLevelIcon(BuildContext context, AppDebugLogLevel level) {
switch (level) {
case AppDebugLogLevel.info:
return const Icon(
Icons.info_outline,
size: 18,
color: MeshPalette.blue,
);
case AppDebugLogLevel.warning:
return const Icon(
Icons.warning_amber_outlined,
size: 18,
color: colorScheme.tertiary,
color: MeshPalette.warn,
);
case AppDebugLogLevel.error:
return Icon(Icons.error_outline, size: 18, color: colorScheme.error);
return const Icon(
Icons.error_outline,
size: 18,
color: MeshPalette.alert,
);
}
}
}
File diff suppressed because it is too large Load Diff
+113 -19
View File
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import '../l10n/l10n.dart';
import '../services/ble_debug_log_service.dart';
import '../connector/meshcore_protocol.dart';
import '../theme/mesh_theme.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart';
@@ -39,6 +40,7 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
return Scaffold(
appBar: AppBar(
title: AdaptiveAppBarTitle(context.l10n.debugLog_bleTitle),
centerTitle: true,
actions: [
IconButton(
tooltip: context.l10n.debugLog_copyLog,
@@ -108,23 +110,14 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
itemCount: showingFrames
? entries.length
: rawEntries.length,
separatorBuilder: (_, _) => const Divider(height: 1),
separatorBuilder: (_, _) =>
const Divider(height: 1, color: MeshPalette.line),
itemBuilder: (context, index) {
if (showingFrames) {
final entry = entries[index];
final time =
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
return ListTile(
dense: true,
title: Text(entry.description),
subtitle: Text('${entry.hexPreview}\n$time'),
isThreeLine: true,
leading: Icon(
entry.outgoing
? Icons.upload
: Icons.download,
size: 18,
),
return GestureDetector(
onLongPress: () async {
await Clipboard.setData(
ClipboardData(
@@ -138,6 +131,60 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
),
);
},
child: Container(
color: MeshPalette.bg,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Icon(
entry.outgoing
? Icons.upload
: Icons.download,
size: 18,
color: entry.outgoing
? MeshPalette.blue
: MeshPalette.signal,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
entry.description,
style: MeshTheme.mono(
fontSize: 11.5,
color: MeshPalette.ink,
),
),
const SizedBox(height: 2),
Text(
entry.hexPreview,
style: MeshTheme.mono(
fontSize: 10,
color: MeshPalette.ink3,
),
),
const SizedBox(height: 2),
Text(
time,
style: MeshTheme.mono(
fontSize: 9.5,
color: MeshPalette.ink4,
),
),
],
),
),
],
),
),
);
}
@@ -145,18 +192,65 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
final info = _decodeRawPacket(entry.payload);
final time =
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
return ListTile(
dense: true,
title: Text(info.title),
subtitle: Text('${info.summary}\n$time'),
isThreeLine: true,
leading: const Icon(Icons.download, size: 18),
return GestureDetector(
onTap: () => _showRawDialog(context, info),
child: Container(
color: MeshPalette.bg,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.download,
size: 18,
color: MeshPalette.signal,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
info.title,
style: MeshTheme.mono(
fontSize: 11.5,
color: MeshPalette.ink,
),
),
const SizedBox(height: 2),
Text(
info.summary,
style: MeshTheme.mono(
fontSize: 10,
color: MeshPalette.ink3,
),
),
const SizedBox(height: 2),
Text(
time,
style: MeshTheme.mono(
fontSize: 9.5,
color: MeshPalette.ink4,
),
),
],
),
),
],
),
),
);
},
)
: Center(
child: Text(context.l10n.debugLog_noBleActivity),
child: Text(
context.l10n.debugLog_noBleActivity,
style: const TextStyle(color: MeshPalette.ink3),
),
),
),
],
+299 -226
View File
@@ -26,7 +26,6 @@ import '../models/translation_support.dart';
import '../services/app_settings_service.dart';
import '../services/chat_text_scale_service.dart';
import '../services/translation_service.dart';
import '../helpers/contact_ui.dart';
import '../widgets/byte_count_input.dart';
import '../widgets/empty_state.dart';
import '../widgets/chat_zoom_wrapper.dart';
@@ -40,6 +39,8 @@ import '../widgets/radio_stats_entry.dart';
import '../widgets/sync_progress_overlay.dart';
import '../widgets/translated_message_content.dart';
import '../widgets/unread_divider.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
import 'channel_message_path_screen.dart';
import 'map_screen.dart';
@@ -109,7 +110,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
totalMessages: messages.length,
onJumped: () {
if (!mounted) return;
_scrollToMessage(anchor!.messageId);
_scrollToMessage(anchor!.messageId, quiet: true);
},
);
});
@@ -194,9 +195,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
});
}
Future<void> _scrollToMessage(String messageId) async {
Future<void> _scrollToMessage(String messageId, {bool quiet = false}) async {
final key = _messageKeys[messageId];
if (key == null) {
// The auto unread-jump can resolve a frame after navigating away;
// a deactivated context can't host a snackbar.
if (quiet || !mounted || !context.mounted) return;
showDismissibleSnackBar(
context,
content: Text(context.l10n.chat_originalMessageNotFound),
@@ -492,6 +496,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final settingsService = context.watch<AppSettingsService>();
final enableTracing = settingsService.settings.enableMessageTracing;
final isOutgoing = message.isOutgoing;
final scheme = Theme.of(context).colorScheme;
final gifId = GifHelper.parseGif(message.text);
final poi = parseMarkerText(message.text);
final translatedDisplayText =
@@ -511,9 +516,34 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
message.pathHashWidth ??
context.read<MeshCoreConnector>().pathHashByteWidth;
// Bubble colors outgoing uses MeshPalette.me / meBorder / meInk.
final bubbleColor = isOutgoing
? MeshPalette.me
: scheme.surfaceContainerLow;
final bubbleBorder = isOutgoing
? MeshPalette.meBorder
: scheme.outlineVariant;
final textColor = isOutgoing ? MeshPalette.meInk : scheme.onSurface;
final metaColor = textColor.withValues(alpha: 0.65);
const bodyFontSize = 14.0;
// Asymmetric radius matching chat_screen bubbles.
final borderRadius = isOutgoing
? const BorderRadius.only(
topLeft: Radius.circular(MeshRadii.lg),
topRight: Radius.circular(MeshRadii.lg),
bottomLeft: Radius.circular(MeshRadii.lg),
bottomRight: Radius.circular(MeshRadii.xs),
)
: const BorderRadius.only(
topLeft: Radius.circular(MeshRadii.xs),
topRight: Radius.circular(MeshRadii.lg),
bottomLeft: Radius.circular(MeshRadii.lg),
bottomRight: Radius.circular(MeshRadii.lg),
);
const maxSwipeOffset = 64.0;
const replySwipeThreshold = 64.0;
const bodyFontSize = 14.0;
final messageBody = LayoutBuilder(
builder: (context, constraints) => Column(
crossAxisAlignment: isOutgoing
@@ -524,11 +554,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
mainAxisAlignment: isOutgoing
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (!isOutgoing) ...[
_buildAvatar(message.senderName),
const SizedBox(width: 8),
const SizedBox(width: 6),
],
Flexible(
child: GestureDetector(
@@ -544,15 +574,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
vertical: 8,
),
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.65,
maxWidth: constraints.maxWidth * 0.72,
),
decoration: BoxDecoration(
color: isOutgoing
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
color: bubbleColor,
borderRadius: borderRadius,
border: Border.all(color: bubbleBorder, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -568,14 +595,14 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
: EdgeInsets.zero,
child: Text(
message.senderName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
style: MeshTheme.mono(
fontSize: 11,
fontWeight: FontWeight.w700,
color: _colorForName(message.senderName),
),
),
),
if (gifId == null) const SizedBox(height: 4),
if (gifId == null) const SizedBox(height: 2),
],
if (message.replyToMessageId != null) ...[
_buildReplyPreview(message, textScale),
@@ -598,13 +625,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
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),
fallbackTextColor: textColor.withValues(
alpha: 0.7,
),
),
),
],
@@ -619,43 +642,51 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
displayText: translatedDisplayText,
originalText: originalDisplayText,
style: TextStyle(
color: textColor,
fontSize: bodyFontSize * textScale,
),
originalStyle: TextStyle(
fontSize: bodyFontSize * textScale,
fontStyle: FontStyle.italic,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.72),
color: textColor.withValues(alpha: 0.72),
),
),
),
],
),
if (enableTracing && displayPath.isNotEmpty) ...[
const SizedBox(height: 4),
const SizedBox(height: 3),
Padding(
padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
context.l10n.channels_via(
_formatPathPrefixes(
displayPath,
displayPathHashWidth,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
RouteChip(
isDirect: (message.pathLength ?? -1) >= 0,
hops: (message.pathLength ?? -1) >= 0
? message.pathLength
: null,
),
),
style: TextStyle(
fontSize: 11 * textScale,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
context.l10n.channels_via(
_formatPathPrefixes(
displayPath,
displayPathHashWidth,
),
),
style: MeshTheme.mono(
fontSize: 9.5 * textScale,
color: metaColor,
),
),
],
),
),
],
const SizedBox(height: 4),
const SizedBox(height: 3),
Padding(
padding: gifId != null
? const EdgeInsets.only(
@@ -669,30 +700,24 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [
Text(
_formatTime(context, message.timestamp),
style: TextStyle(
fontSize: 11 * textScale,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
style: MeshTheme.mono(
fontSize: 10 * textScale,
color: metaColor,
),
),
if (enableTracing && message.repeatCount > 0) ...[
const SizedBox(width: 6),
Icon(
Icons.repeat,
size: 12 * textScale,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
size: 11 * textScale,
color: metaColor,
),
const SizedBox(width: 2),
Text(
'${message.repeatCount}',
style: TextStyle(
fontSize: 11 * textScale,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
style: MeshTheme.mono(
fontSize: 10 * textScale,
color: metaColor,
),
),
],
@@ -712,6 +737,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
isFailed:
message.status ==
ChannelMessageStatus.failed,
onColor: metaColor,
),
],
],
@@ -727,7 +753,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
if (message.reactions.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
padding: EdgeInsets.only(left: isOutgoing ? 0 : 42),
child: _buildReactionsDisplay(message),
),
],
@@ -746,7 +772,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
} else {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(vertical: 3),
child: messageBody,
);
}
@@ -843,7 +869,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(MeshRadii.sm),
border: Border(
left: BorderSide(color: colorScheme.primary, width: 3),
),
@@ -856,9 +882,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
style: TextStyle(
fontSize: 11 * textScale,
fontWeight: FontWeight.bold,
color: isOwnNode
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
color: isOwnNode ? colorScheme.primary : colorScheme.onSurface,
),
),
const SizedBox(height: 2),
@@ -870,6 +894,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}
Widget _buildReactionsDisplay(ChannelMessage message) {
final scheme = Theme.of(context).colorScheme;
return Wrap(
spacing: 6,
runSpacing: 6,
@@ -880,27 +905,29 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(MeshRadii.pill),
border: Border.all(color: scheme.outlineVariant, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(emoji, style: const TextStyle(fontSize: 16)),
Text(
emoji,
style: MeshTheme.emoji(fontSize: 16),
textHeightBehavior: const TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
),
),
if (count > 1) ...[
const SizedBox(width: 4),
Text(
'$count',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSecondaryContainer,
style: MeshTheme.mono(
fontSize: 11,
fontWeight: FontWeight.w700,
color: scheme.onSurface,
),
),
],
@@ -919,20 +946,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
String senderName, {
Widget? trailing,
}) {
final colorScheme = Theme.of(context).colorScheme;
final textColor = isOutgoing
? colorScheme.onPrimaryContainer
: colorScheme.onSurface;
final scheme = Theme.of(context).colorScheme;
final textColor = isOutgoing ? MeshPalette.meInk : scheme.onSurface;
final metaColor = textColor.withValues(alpha: 0.7);
final channelColor = widget.channel.isPublicChannel
? Colors.orange
: Colors.blue;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: Icon(Icons.location_on_outlined, color: channelColor),
icon: Icon(Icons.location_on_outlined, color: scheme.primary),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
onPressed: () {
@@ -998,41 +1020,24 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}
Widget _buildAvatar(String senderName) {
final initial = firstCharacterOrEmoji(senderName);
final color = colorForName(senderName);
return CircleAvatar(
radius: 18,
backgroundColor: color.withValues(alpha: 0.2),
child: Text(
initial,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: color,
),
),
);
return AvatarCircle(name: senderName, size: 32);
}
Widget _buildReplyBanner(double textScale) {
final message = _replyingToMessage!;
final scheme = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
color: scheme.surfaceContainerHigh,
border: Border(
bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1),
bottom: BorderSide(color: scheme.outlineVariant, width: 1),
),
),
child: Row(
children: [
Icon(
Icons.reply,
size: 18,
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
Icon(Icons.reply, size: 18, color: scheme.primary),
const SizedBox(width: 8),
Expanded(
child: Column(
@@ -1040,10 +1045,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [
Text(
context.l10n.chat_replyingTo(message.senderName),
style: TextStyle(
fontSize: 12 * textScale,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSecondaryContainer,
style: MeshTheme.mono(
fontSize: 11 * textScale,
fontWeight: FontWeight.w700,
color: scheme.primary,
),
),
Text(
@@ -1052,9 +1057,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11 * textScale,
color: Theme.of(
context,
).colorScheme.onSecondaryContainer.withValues(alpha: 0.7),
color: scheme.onSurfaceVariant,
),
),
],
@@ -1063,7 +1066,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: _cancelReply,
color: Theme.of(context).colorScheme.onSecondaryContainer,
color: scheme.onSurfaceVariant,
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
],
@@ -1075,6 +1078,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final connector = context.watch<MeshCoreConnector>();
final maxBytes = maxChannelMessageBytes(connector.selfName);
final settings = context.watch<AppSettingsService>().settings;
final scheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -1088,123 +1092,166 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
},
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
color: scheme.surface,
border: Border(
top: BorderSide(color: scheme.outlineVariant, width: 1),
),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: context.l10n.chat_sendGif,
),
if (settings.translationEnabled)
MessageTranslationButton(
enabled: settings.composerTranslationEnabled,
languageCode: settings.translationTargetLanguageCode,
onPressed: _showTranslationOptions,
),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = GifHelper.parseGif(value.text);
if (gifId != null) {
return Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
(event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey ==
LogicalKeyboardKey.numpadEnter)) {
_sendMessage();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
fallbackTextColor: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
maxSize: 160,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: context.l10n.chat_sendGif,
),
if (settings.translationEnabled)
MessageTranslationButton(
enabled: settings.composerTranslationEnabled,
languageCode: settings.translationTargetLanguageCode,
onPressed: _showTranslationOptions,
),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = GifHelper.parseGif(value.text);
if (gifId != null) {
return Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
(event.logicalKey ==
LogicalKeyboardKey.enter ||
event.logicalKey ==
LogicalKeyboardKey.numpadEnter)) {
_sendMessage();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor:
scheme.surfaceContainerHighest,
fallbackTextColor: scheme.onSurface
.withValues(alpha: 0.6),
maxSize: 160,
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_textController.clear();
_textFieldFocusNode.requestFocus();
},
),
],
),
);
}
return ByteCountedTextField(
maxBytes: maxBytes,
controller: _textController,
focusNode: _textFieldFocusNode,
hintText: context.l10n.chat_typeMessage,
onSubmitted: (_) => _sendMessage(),
encoder:
(connector.isChannelSmazEnabled(
widget.channel.index,
) ||
connector.isChannelCyr2LatEnabled(
widget.channel.index,
))
? (text) => connector.prepareChannelOutboundText(
widget.channel.index,
text,
)
: null,
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(
MeshRadii.pill,
),
borderSide: BorderSide(
color: scheme.outlineVariant,
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_textController.clear();
_textFieldFocusNode.requestFocus();
},
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(
MeshRadii.pill,
),
borderSide: BorderSide(
color: scheme.outlineVariant,
),
),
],
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(
MeshRadii.pill,
),
borderSide: BorderSide(
color: scheme.primary,
width: 1.5,
),
),
filled: true,
fillColor: scheme.surfaceContainerLow,
contentPadding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 12,
),
),
);
},
),
),
const SizedBox(width: 6),
ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, _) {
final hasText = value.text.trim().isNotEmpty;
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeInOut,
child: IconButton.filled(
icon: const Icon(Icons.send, size: 20),
tooltip: context.l10n.chat_sendMessage,
style: IconButton.styleFrom(
backgroundColor: hasText
? scheme.primary
: scheme.surfaceContainerHighest,
foregroundColor: hasText
? scheme.onPrimary
: scheme.onSurfaceVariant,
minimumSize: const Size(40, 40),
shape: const CircleBorder(),
),
onPressed: hasText
? () {
HapticFeedback.lightImpact();
_sendMessage();
}
: null,
),
);
}
return ByteCountedTextField(
maxBytes: maxBytes,
controller: _textController,
focusNode: _textFieldFocusNode,
hintText: context.l10n.chat_typeMessage,
onSubmitted: (_) => _sendMessage(),
encoder:
(connector.isChannelSmazEnabled(
widget.channel.index,
) ||
connector.isChannelCyr2LatEnabled(
widget.channel.index,
))
? (text) => connector.prepareChannelOutboundText(
widget.channel.index,
text,
)
: null,
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
filled: true,
fillColor: Theme.of(
context,
).colorScheme.surfaceContainerLow,
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 14,
),
),
);
},
),
},
),
],
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.send),
tooltip: context.l10n.chat_sendMessage,
onPressed: _sendMessage,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
),
],
@@ -1345,12 +1392,20 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
) &&
(message.translatedText?.trim().isEmpty ?? true);
showModalBottomSheet(
context: context,
showMeshSheet(
context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
BottomSheetHeader(
title: message.text.length > 40
? '${message.text.substring(0, 40)}'
: message.text,
subtitle: message.senderName.isNotEmpty
? message.senderName
: null,
),
ListTile(
leading: const Icon(Icons.reply),
title: Text(context.l10n.chat_reply),
@@ -1409,7 +1464,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_markAsUnread(message);
},
),
const Divider(),
const Divider(height: 1),
ListTile(
leading: Icon(
Icons.delete_outline,
@@ -1424,6 +1479,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
await _deleteMessage(message);
},
),
const SizedBox(height: 8),
],
),
),
@@ -1507,6 +1563,23 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
pathHashByteWidth,
).map(PathHelper.formatHopHex).join(',');
}
/// Deterministic name-to-hue mapping consistent with [AvatarCircle].
Color _colorForName(String name) {
const hues = [
MeshPalette.blue,
MeshPalette.magenta,
MeshPalette.signal,
MeshPalette.warn,
Color(0xFF8FA8F0),
Color(0xFF6FD9CE),
];
var h = 0;
for (final c in name.codeUnits) {
h = (h * 31 + c) & 0x7fffffff;
}
return hues[h % hues.length];
}
}
class _SwipeReplyBubble extends StatefulWidget {
@@ -1648,7 +1721,7 @@ class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> {
onPointerUp: (event) => _handleSwipePointerUp(event.position),
onPointerCancel: (_) => _resetSwipe(),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 8),
child: Stack(
alignment: Alignment.center,
children: [
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+267 -180
View File
@@ -31,7 +31,6 @@ import '../widgets/chat_zoom_wrapper.dart';
import '../widgets/byte_count_input.dart';
import 'channel_message_path_screen.dart';
import 'map_screen.dart';
import '../helpers/contact_ui.dart';
import '../widgets/emoji_picker.dart';
import '../widgets/gif_message.dart';
import '../widgets/jump_to_bottom_button.dart';
@@ -44,6 +43,8 @@ import '../widgets/translated_message_content.dart';
import '../l10n/l10n.dart';
import '../helpers/snack_bar_builder.dart';
import '../widgets/unread_divider.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
import 'telemetry_screen.dart';
class ChatScreen extends StatefulWidget {
@@ -381,12 +382,14 @@ class _ChatScreenState extends State<ChatScreen> {
}
final messageIndex = index;
Contact contact = _resolveContact(connector);
final bool isRoom = contact.type == advTypeRoom;
final message = reversedMessages[messageIndex];
String fourByteHex = '';
if (contact.type == advTypeRoom) {
Contact? roomAuthor;
if (isRoom) {
// Room-server messages carry the original author's 4-byte prefix
// separately from message.text; use it only for resolving the name.
contact = _resolveContactFrom4Bytes(
roomAuthor = _resolveContactFrom4Bytes(
connector,
message.fourByteRoomContactKey.isEmpty
? Uint8List.fromList([0, 0, 0, 0])
@@ -396,6 +399,9 @@ class _ChatScreenState extends State<ChatScreen> {
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase();
// Only adopt the author identity when we actually know them; never
// fall back to the room server's own name as the sender.
if (roomAuthor != null) contact = roomAuthor;
}
return Builder(
@@ -403,11 +409,12 @@ class _ChatScreenState extends State<ChatScreen> {
final textScale = context.select<ChatTextScaleService, double>(
(service) => service.scale,
);
final resolvedContact = _resolveContact(connector);
final bubble = _MessageBubble(
message: message,
senderName: resolvedContact.type == advTypeRoom
? "${contact.name} [$fourByteHex]"
senderName: isRoom
? (roomAuthor != null
? "${roomAuthor.name} [$fourByteHex]"
: "[$fourByteHex]")
: contact.name,
sourceId: widget.contact.publicKeyHex,
textScale: textScale,
@@ -449,119 +456,157 @@ class _ChatScreenState extends State<ChatScreen> {
Widget _buildInputBar(MeshCoreConnector connector) {
final maxBytes = maxContactMessageBytes();
final colorScheme = Theme.of(context).colorScheme;
final scheme = Theme.of(context).colorScheme;
final settings = context.watch<AppSettingsService>().settings;
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface,
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
color: scheme.surface,
border: Border(top: BorderSide(color: scheme.outlineVariant, width: 1)),
),
child: SafeArea(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: context.l10n.chat_sendGif,
),
if (settings.translationEnabled)
MessageTranslationButton(
enabled: settings.composerTranslationEnabled,
languageCode: settings.translationTargetLanguageCode,
onPressed: _showTranslationOptions,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: context.l10n.chat_sendGif,
),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = GifHelper.parseGif(value.text);
if (gifId != null) {
return Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
(event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey ==
LogicalKeyboardKey.numpadEnter)) {
_sendMessage(connector);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor:
colorScheme.surfaceContainerHighest,
fallbackTextColor: colorScheme.onSurface
.withValues(alpha: 0.6),
maxSize: 160,
if (settings.translationEnabled)
MessageTranslationButton(
enabled: settings.composerTranslationEnabled,
languageCode: settings.translationTargetLanguageCode,
onPressed: _showTranslationOptions,
),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = GifHelper.parseGif(value.text);
if (gifId != null) {
return Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
(event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey ==
LogicalKeyboardKey.numpadEnter)) {
_sendMessage(connector);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor:
scheme.surfaceContainerHighest,
fallbackTextColor: scheme.onSurface
.withValues(alpha: 0.6),
maxSize: 160,
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_textController.clear();
_textFieldFocusNode.requestFocus();
},
),
],
),
);
}
return ByteCountedTextField(
maxBytes: maxBytes,
controller: _textController,
focusNode: _textFieldFocusNode,
hintText: context.l10n.chat_typeMessage,
onSubmitted: (_) => _sendMessage(connector),
encoder:
(connector.isContactSmazEnabled(
widget.contact.publicKeyHex,
) ||
connector.isContactCyr2LatEnabled(
widget.contact.publicKeyHex,
))
? (text) => connector.prepareContactOutboundText(
widget.contact,
text,
)
: null,
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(MeshRadii.pill),
borderSide: BorderSide(color: scheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(MeshRadii.pill),
borderSide: BorderSide(color: scheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(MeshRadii.pill),
borderSide: BorderSide(
color: scheme.primary,
width: 1.5,
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_textController.clear();
_textFieldFocusNode.requestFocus();
},
),
],
),
filled: true,
fillColor: scheme.surfaceContainerLow,
contentPadding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 12,
),
),
);
}
return ByteCountedTextField(
maxBytes: maxBytes,
controller: _textController,
focusNode: _textFieldFocusNode,
hintText: context.l10n.chat_typeMessage,
onSubmitted: (_) => _sendMessage(connector),
encoder:
(connector.isContactSmazEnabled(
widget.contact.publicKeyHex,
) ||
connector.isContactCyr2LatEnabled(
widget.contact.publicKeyHex,
))
? (text) => connector.prepareContactOutboundText(
widget.contact,
text,
)
: null,
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
},
),
),
const SizedBox(width: 6),
ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, _) {
final hasText = value.text.trim().isNotEmpty;
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeInOut,
child: IconButton.filled(
icon: const Icon(Icons.send, size: 20),
tooltip: context.l10n.chat_sendMessageTo(
_resolveContact(connector).name,
),
filled: true,
fillColor: Theme.of(
context,
).colorScheme.surfaceContainerLow,
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 14,
style: IconButton.styleFrom(
backgroundColor: hasText
? scheme.primary
: scheme.surfaceContainerHighest,
foregroundColor: hasText
? scheme.onPrimary
: scheme.onSurfaceVariant,
minimumSize: const Size(40, 40),
shape: const CircleBorder(),
),
onPressed: hasText
? () {
HapticFeedback.lightImpact();
_sendMessage(connector);
}
: null,
),
);
},
),
),
const SizedBox(width: 8),
IconButton.filled(
icon: const Icon(Icons.send),
tooltip: context.l10n.chat_sendMessageTo(
_resolveContact(connector).name,
),
onPressed: () => _sendMessage(connector),
),
],
],
),
),
),
);
@@ -717,13 +762,17 @@ class _ChatScreenState extends State<ChatScreen> {
return connector.contacts[_resolveContactIndex];
}
Contact _resolveContactFrom4Bytes(
Contact? _resolveContactFrom4Bytes(
MeshCoreConnector connector,
Uint8List key4Bytes,
) {
return connector.contacts.firstWhere(
(c) => listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)),
orElse: () => widget.contact,
// Match against saved contacts first, then nodes only seen via discovery
// a room poster you haven't saved may still be in the discovered list.
return connector.allContactsUnfiltered.cast<Contact?>().firstWhere(
(c) =>
c != null &&
listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)),
orElse: () => null,
);
}
@@ -1032,7 +1081,11 @@ class _ChatScreenState extends State<ChatScreen> {
if (message.isOutgoing) {
senderName = connector.selfName ?? context.l10n.chat_me;
} else if (_resolveContact(connector).type == advTypeRoom) {
senderName = "${contact.name} [$fourByteHex]";
// An unresolved author leaves `contact` as the room server itself; show
// only the prefix rather than mislabeling the post with the room's name.
senderName = contact.type == advTypeRoom
? "[$fourByteHex]"
: "${contact.name} [$fourByteHex]";
} else {
senderName = _resolveContact(connector).name;
}
@@ -1065,12 +1118,17 @@ class _ChatScreenState extends State<ChatScreen> {
) &&
(message.translatedText?.trim().isEmpty ?? true);
showModalBottomSheet(
context: context,
showMeshSheet(
context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
BottomSheetHeader(
title: message.text.length > 40
? '${message.text.substring(0, 40)}'
: message.text,
),
// Can't react to your own messages
if (!message.isOutgoing)
ListTile(
@@ -1140,7 +1198,7 @@ class _ChatScreenState extends State<ChatScreen> {
_openChat(context, contact);
},
),
const Divider(),
const Divider(height: 1),
ListTile(
leading: Icon(
Icons.delete_outline,
@@ -1155,6 +1213,7 @@ class _ChatScreenState extends State<ChatScreen> {
await _deleteMessage(message);
},
),
const SizedBox(height: 8),
],
),
),
@@ -1243,20 +1302,45 @@ class _MessageBubble extends StatelessWidget {
final settingsService = context.watch<AppSettingsService>();
final enableTracing = settingsService.settings.enableMessageTracing;
final isOutgoing = message.isOutgoing;
final colorScheme = Theme.of(context).colorScheme;
final scheme = Theme.of(context).colorScheme;
final gifId = GifHelper.parseGif(message.text);
final poi = parseMarkerText(message.text);
final isFailed = message.status == MessageStatus.failed;
// Bubble colors outgoing uses MeshPalette.me / meBorder / meInk.
final bubbleColor = isFailed
? colorScheme.errorContainer
: (isOutgoing
? colorScheme.primary
: colorScheme.surfaceContainerHighest);
? scheme.errorContainer
: isOutgoing
? MeshPalette.me
: scheme.surfaceContainerLow;
final bubbleBorder = isFailed
? scheme.error
: isOutgoing
? MeshPalette.meBorder
: scheme.outlineVariant;
final textColor = isFailed
? colorScheme.onErrorContainer
: (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface);
final metaColor = textColor.withValues(alpha: 0.7);
? scheme.onErrorContainer
: isOutgoing
? MeshPalette.meInk
: scheme.onSurface;
final metaColor = textColor.withValues(alpha: 0.65);
const bodyFontSize = 14.0;
// Asymmetric radius: outgoing top-left large, others also large; outgoing bottom-right tight.
final borderRadius = isOutgoing
? const BorderRadius.only(
topLeft: Radius.circular(MeshRadii.lg),
topRight: Radius.circular(MeshRadii.lg),
bottomLeft: Radius.circular(MeshRadii.lg),
bottomRight: Radius.circular(MeshRadii.xs),
)
: const BorderRadius.only(
topLeft: Radius.circular(MeshRadii.xs),
topRight: Radius.circular(MeshRadii.lg),
bottomLeft: Radius.circular(MeshRadii.lg),
bottomRight: Radius.circular(MeshRadii.lg),
);
// Do not strip room-server author bytes here: the parser stores them in
// fourByteRoomContactKey, so message.text is safe to render as-is.
final messageText = message.text;
@@ -1268,8 +1352,9 @@ class _MessageBubble extends StatelessWidget {
final originalDisplayText = isOutgoing
? message.originalText
: (translatedDisplayText != messageText ? messageText : null);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(vertical: 3),
child: Column(
crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end
@@ -1285,11 +1370,11 @@ class _MessageBubble extends StatelessWidget {
mainAxisAlignment: isOutgoing
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (!isOutgoing) ...[
_buildAvatar(senderName, colorScheme),
const SizedBox(width: 8),
_buildAvatar(senderName),
const SizedBox(width: 6),
],
Flexible(
child: Container(
@@ -1300,14 +1385,12 @@ class _MessageBubble extends StatelessWidget {
vertical: 8,
),
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.65,
maxWidth: constraints.maxWidth * 0.72,
),
decoration: BoxDecoration(
color: bubbleColor,
borderRadius: BorderRadius.circular(16),
border: isFailed
? Border.all(color: colorScheme.error)
: null,
borderRadius: borderRadius,
border: Border.all(color: bubbleBorder, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1323,14 +1406,14 @@ class _MessageBubble extends StatelessWidget {
: EdgeInsets.zero,
child: Text(
senderName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
style: MeshTheme.mono(
fontSize: 11,
fontWeight: FontWeight.w700,
color: _colorForName(senderName),
),
),
),
if (gifId == null) const SizedBox(height: 4),
if (gifId == null) const SizedBox(height: 2),
],
if (poi != null)
_buildPoiMessage(
@@ -1371,7 +1454,7 @@ class _MessageBubble extends StatelessWidget {
fontSize: bodyFontSize * textScale,
),
originalStyle: TextStyle(
color: textColor.withValues(alpha: 0.78),
color: textColor.withValues(alpha: 0.72),
fontSize: bodyFontSize * textScale,
),
),
@@ -1381,7 +1464,7 @@ class _MessageBubble extends StatelessWidget {
if (enableTracing &&
isOutgoing &&
message.retryCount > 0) ...[
const SizedBox(height: 4),
const SizedBox(height: 3),
Padding(
padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8)
@@ -1394,15 +1477,15 @@ class _MessageBubble extends StatelessWidget {
.settings
.maxMessageRetries,
),
style: TextStyle(
fontSize: 10 * textScale,
style: MeshTheme.mono(
fontSize: 9.5 * textScale,
color: metaColor,
fontWeight: FontWeight.w500,
),
),
),
],
const SizedBox(height: 4),
const SizedBox(height: 3),
// Meta row: timestamp + status icon + optional tracing
Padding(
padding: gifId != null
? const EdgeInsets.only(
@@ -1417,13 +1500,13 @@ class _MessageBubble extends StatelessWidget {
children: [
Text(
_formatTime(message.timestamp),
style: TextStyle(
style: MeshTheme.mono(
fontSize: 10 * textScale,
color: metaColor,
),
),
if (isOutgoing) ...[
const SizedBox(width: 4),
const SizedBox(width: 2),
MessageStatusIcon(
size: 12 * textScale,
onColor: metaColor,
@@ -1440,25 +1523,21 @@ class _MessageBubble extends StatelessWidget {
message.tripTimeMs != null &&
message.status ==
MessageStatus.delivered) ...[
const SizedBox(width: 4),
const SizedBox(width: 2),
Icon(
Icons.speed,
size: 10 * textScale,
color: isOutgoing
? metaColor
: Theme.of(
context,
).colorScheme.tertiary,
: scheme.tertiary,
),
Text(
'${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s',
style: TextStyle(
style: MeshTheme.mono(
fontSize: 9 * textScale,
color: isOutgoing
? metaColor
: Theme.of(
context,
).colorScheme.tertiary,
: scheme.tertiary,
),
),
],
@@ -1476,8 +1555,8 @@ class _MessageBubble extends StatelessWidget {
if (message.reactions.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
child: _buildReactionsDisplay(context, message, colorScheme),
padding: EdgeInsets.only(left: isOutgoing ? 0 : 42),
child: _buildReactionsDisplay(context, message, scheme),
),
],
],
@@ -1554,7 +1633,7 @@ class _MessageBubble extends StatelessWidget {
Widget _buildReactionsDisplay(
BuildContext context,
Message message,
ColorScheme colorScheme,
ColorScheme scheme,
) {
return Wrap(
spacing: 6,
@@ -1577,28 +1656,33 @@ class _MessageBubble extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isFailed
? colorScheme.errorContainer
: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
? scheme.errorContainer
: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(MeshRadii.pill),
border: Border.all(
color: isFailed
? colorScheme.error
: colorScheme.outline.withValues(alpha: 0.3),
color: isFailed ? scheme.error : scheme.outlineVariant,
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(emoji, style: const TextStyle(fontSize: 16)),
Text(
emoji,
style: MeshTheme.emoji(fontSize: 16),
textHeightBehavior: const TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
),
),
if (count > 1) ...[
const SizedBox(width: 4),
Text(
'$count',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.onSecondaryContainer,
style: MeshTheme.mono(
fontSize: 11,
fontWeight: FontWeight.w700,
color: scheme.onSurface,
),
),
],
@@ -1609,13 +1693,13 @@ class _MessageBubble extends StatelessWidget {
height: 8,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: colorScheme.onSecondaryContainer,
color: scheme.primary,
),
),
],
if (isFailed) ...[
const SizedBox(width: 2),
Icon(Icons.replay, size: 10, color: colorScheme.error),
Icon(Icons.replay, size: 10, color: scheme.error),
],
],
),
@@ -1626,22 +1710,8 @@ class _MessageBubble extends StatelessWidget {
);
}
Widget _buildAvatar(String senderName, ColorScheme colorScheme) {
final initial = firstCharacterOrEmoji(senderName);
final color = colorForName(senderName);
return CircleAvatar(
radius: 18,
backgroundColor: color.withValues(alpha: 0.2),
child: Text(
initial,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: color,
),
),
);
Widget _buildAvatar(String senderName) {
return AvatarCircle(name: senderName, size: 32);
}
String _formatTime(DateTime time) {
@@ -1650,3 +1720,20 @@ class _MessageBubble extends StatelessWidget {
return '$hour:$minute';
}
}
/// Deterministic name-to-hue mapping consistent with [AvatarCircle].
Color _colorForName(String name) {
const hues = [
MeshPalette.blue,
MeshPalette.magenta,
MeshPalette.signal,
MeshPalette.warn,
Color(0xFF8FA8F0),
Color(0xFF6FD9CE),
];
var h = 0;
for (final c in name.codeUnits) {
h = (h * 31 + c) & 0x7fffffff;
}
return hues[h % hues.length];
}
+87 -68
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
class ChromeRequiredScreen extends StatelessWidget {
const ChromeRequiredScreen({super.key});
@@ -7,78 +9,95 @@ class ChromeRequiredScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final scheme = Theme.of(context).colorScheme;
return Scaffold(
body: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 32),
color: colorScheme.surface,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: Icon(
Icons.browser_not_supported_rounded,
size: 80,
color: colorScheme.tertiary,
),
),
const SizedBox(height: 32),
Text(
l10n.scanner_chromeRequired,
textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 16),
Text(
l10n.scanner_chromeRequiredMessage,
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
const SizedBox(height: 48),
// We can't really "fix" it for them other than telling them to use Chrome
// but we can provide a nice visual.
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(30),
border: Border.all(
color: colorScheme.outline.withValues(alpha: 0.4),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.info_outline,
size: 20,
color: colorScheme.secondary,
),
const SizedBox(width: 12),
Text(
l10n.chrome_bluetoothRequiresChromium,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w500,
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 40),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icon in tinted circle
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: scheme.tertiary.withValues(alpha: 0.10),
border: Border.all(
color: scheme.tertiary.withValues(alpha: 0.25),
width: 1.5,
),
),
],
),
child: Icon(
Icons.browser_not_supported_rounded,
size: 42,
color: scheme.tertiary,
),
),
const SizedBox(height: 28),
// Title
Text(
l10n.scanner_chromeRequired,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
color: scheme.onSurface,
letterSpacing: -0.3,
),
),
const SizedBox(height: 12),
// Body text
Text(
l10n.scanner_chromeRequiredMessage,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: scheme.onSurfaceVariant,
height: 1.55,
),
),
const SizedBox(height: 32),
// Info chip
MeshCard(
margin: EdgeInsets.zero,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
color: scheme.secondaryContainer.withValues(alpha: 0.35),
borderColor: scheme.outline.withValues(alpha: 0.3),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.info_outline,
size: 18,
color: scheme.secondary,
),
const SizedBox(width: 10),
Flexible(
child: Text(
l10n.chrome_bluetoothRequiresChromium,
style: MeshTheme.mono(
fontSize: 12,
color: scheme.onSecondaryContainer,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
],
),
),
],
),
],
),
),
),
);
+229 -75
View File
@@ -1,14 +1,18 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart';
import '../connector/meshcore_connector.dart';
import '../helpers/snack_bar_builder.dart';
import '../l10n/l10n.dart';
import '../models/community.dart';
import '../storage/community_store.dart';
import '../theme/mesh_theme.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/mesh_ui.dart';
import '../widgets/qr_scanner_widget.dart';
import '../helpers/snack_bar_builder.dart';
/// Screen for scanning community QR codes to join communities.
///
@@ -35,16 +39,87 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
centerTitle: true,
),
body: _isProcessing
? const Center(child: CircularProgressIndicator())
? Container(
color: Theme.of(context).colorScheme.surface,
child: const Center(child: CircularProgressIndicator()),
)
: QrScannerWidget(
onScanned: (data) => _handleScannedData(context, data),
validator: Community.isValidQrData,
onValidationFailed: (_) => _showInvalidQrError(context),
instructions: context.l10n.community_scanInstructions,
overlay: _buildThemedOverlay(context),
),
);
}
Widget _buildThemedOverlay(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
// Dark semi-transparent background with cutout
ColorFiltered(
colorFilter: ColorFilter.mode(
Colors.black.withValues(alpha: 0.5),
BlendMode.srcOut,
),
child: Stack(
fit: StackFit.expand,
children: [
Container(
decoration: const BoxDecoration(
color: Colors.black,
backgroundBlendMode: BlendMode.dstOut,
),
),
Center(
child: Container(
height: 250,
width: 250,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(16),
),
),
),
],
),
),
// Corner brackets on top
const ScannerCornerOverlay(
scanWindowSize: 250,
borderColor: MeshPalette.blue,
borderWidth: 2,
cornerLength: 24,
),
// Instructions pill below the scan window
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 250 + 24),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(MeshRadii.pill),
),
child: Text(
context.l10n.community_scanInstructions,
style: const TextStyle(color: MeshPalette.ink2, fontSize: 13),
textAlign: TextAlign.center,
),
),
],
),
),
],
);
}
Future<void> _handleScannedData(BuildContext context, String data) async {
if (_isProcessing) return;
@@ -80,7 +155,7 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
showDismissibleSnackBar(
context,
content: Text(context.l10n.community_invalidQrCode),
backgroundColor: Colors.red,
backgroundColor: MeshPalette.alert,
);
}
} finally {
@@ -96,29 +171,74 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
showDismissibleSnackBar(
context,
content: Text(context.l10n.community_invalidQrCode),
backgroundColor: Colors.orange,
backgroundColor: MeshPalette.warn,
duration: const Duration(seconds: 2),
);
}
void _showAlreadyMemberDialog(BuildContext context, Community community) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(context.l10n.community_alreadyMember),
content: Text(
context.l10n.community_alreadyMemberMessage(community.name),
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(dialogContext);
Navigator.pop(context);
},
child: Text(context.l10n.common_ok),
),
],
),
showMeshSheet(
context,
builder: (sheetContext) {
final sheetScheme = Theme.of(sheetContext).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
BottomSheetHeader(title: context.l10n.community_alreadyMember),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
child: Text(
context.l10n.community_alreadyMemberMessage(community.name),
style: TextStyle(color: sheetScheme.onSurfaceVariant),
),
),
MeshCard(
child: Row(
children: [
const Icon(
Icons.groups,
color: MeshPalette.magenta,
size: 32,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
community.name,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
),
),
Text(
'ID: ${community.shortCommunityId}...',
style: MeshTheme.mono(
fontSize: 11.5,
color: sheetScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: FilledButton(
onPressed: () {
Navigator.pop(sheetContext);
Navigator.pop(context);
},
child: Text(context.l10n.common_ok),
),
),
],
);
},
);
}
@@ -127,77 +247,111 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
Community community,
) async {
bool addPublicChannel = true;
final completer = Completer<bool>();
final result = await showDialog<bool>(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (dialogContext, setDialogState) => AlertDialog(
title: Text(context.l10n.community_joinTitle),
content: Column(
await showMeshSheet<void>(
context,
builder: (sheetContext) => StatefulBuilder(
builder: (sheetContext, setSheetState) {
final joinScheme = Theme.of(sheetContext).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(context.l10n.community_joinConfirmation(community.name)),
const SizedBox(height: 16),
Row(
children: [
Icon(
Icons.groups,
color: Theme.of(dialogContext).colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
community.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
'ID: ${community.shortCommunityId}...',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
],
BottomSheetHeader(title: context.l10n.community_joinTitle),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
child: Text(
context.l10n.community_joinConfirmation(community.name),
style: TextStyle(color: joinScheme.onSurfaceVariant),
),
),
MeshCard(
child: Row(
children: [
AvatarCircle(
name: community.name,
icon: Icons.groups,
color: MeshPalette.magenta,
size: 44,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
community.name,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
),
),
Text(
'ID: ${community.shortCommunityId}...',
style: MeshTheme.mono(
fontSize: 11.5,
color: joinScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
CheckboxListTile(
value: addPublicChannel,
onChanged: (value) {
setDialogState(() {
setSheetState(() {
addPublicChannel = value ?? true;
});
},
title: Text(context.l10n.community_addPublicChannel),
subtitle: Text(context.l10n.community_addPublicChannelHint),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
completer.complete(false);
Navigator.pop(sheetContext);
},
child: Text(context.l10n.common_cancel),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () {
completer.complete(true);
Navigator.pop(sheetContext);
},
child: Text(context.l10n.community_join),
),
),
],
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(context.l10n.common_cancel),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(context.l10n.community_join),
),
],
),
);
},
),
);
if (result == true && context.mounted) {
// If sheet was dismissed without a button press, treat as cancel
if (!completer.isCompleted) {
completer.complete(false);
}
final result = await completer.future;
if (result && context.mounted) {
await _joinCommunity(context, community, addPublicChannel);
} else if (context.mounted) {
// User cancelled - go back
@@ -231,7 +385,7 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
showDismissibleSnackBar(
context,
content: Text(context.l10n.community_joined(community.name)),
backgroundColor: Colors.green,
backgroundColor: MeshPalette.signal,
);
// Return to previous screen
+111 -29
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/models/companion_radio_stats.dart';
import 'package:meshcore_open/l10n/l10n.dart';
import 'package:meshcore_open/theme/mesh_theme.dart';
import 'package:meshcore_open/widgets/mesh_ui.dart';
import 'package:provider/provider.dart';
class CompanionRadioStatsScreen extends StatefulWidget {
@@ -49,6 +51,25 @@ class _CompanionRadioStatsScreenState extends State<CompanionRadioStatsScreen> {
super.dispose();
}
Widget _tile(String text, IconData icon, Color color) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 10),
Expanded(
child: Text(
text,
style: MeshTheme.mono(fontSize: 13, color: scheme.onSurface),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
@@ -85,44 +106,105 @@ class _CompanionRadioStatsScreenState extends State<CompanionRadioStatsScreen> {
valueListenable: connector.radioStatsNotifier,
builder: (context, stats, _) {
return ListView(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
if (stats != null) ...[
Text(
l10n.radioStats_noiseFloor(stats.noiseFloorDbm),
style: tt.titleMedium,
const SectionHeader(
'Signal',
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
),
const SizedBox(height: 4),
Text(l10n.radioStats_lastRssi(stats.lastRssiDbm)),
Text(
l10n.radioStats_lastSnr(
stats.lastSnrDb.toStringAsFixed(1),
MeshCard(
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
padding: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_tile(
l10n.radioStats_noiseFloor(stats.noiseFloorDbm),
Icons.noise_aware,
scheme.onSurfaceVariant,
),
const Divider(height: 1),
_tile(
l10n.radioStats_lastRssi(stats.lastRssiDbm),
Icons.wifi_tethering,
scheme.onSurfaceVariant,
),
const Divider(height: 1),
_tile(
l10n.radioStats_lastSnr(
stats.lastSnrDb.toStringAsFixed(1),
),
Icons.signal_cellular_alt,
MeshTheme.snrColor(stats.lastSnrDb, blocked: false),
),
],
),
),
Text(l10n.radioStats_txAir(stats.txAirSecs)),
Text(l10n.radioStats_rxAir(stats.rxAirSecs)),
const SizedBox(height: 16),
] else
Text(l10n.radioStats_waiting),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: CustomPaint(
painter: _NoiseChartPainter(
samples: List<double>.from(_noiseHistory),
colorScheme: scheme,
textTheme: tt,
const SectionHeader(
'Airtime',
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
),
MeshCard(
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
padding: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_tile(
l10n.radioStats_txAir(stats.txAirSecs),
Icons.upload,
MeshPalette.blue,
),
const Divider(height: 1),
_tile(
l10n.radioStats_rxAir(stats.rxAirSecs),
Icons.download,
MeshPalette.blue,
),
],
),
),
] else ...[
const SizedBox(height: 80),
Center(
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 8),
Center(
child: Text(
l10n.radioStats_waiting,
style: TextStyle(color: scheme.onSurfaceVariant),
),
),
],
SectionHeader(
l10n.radioStats_chartCaption,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
height: 200,
child: CustomPaint(
painter: _NoiseChartPainter(
samples: List<double>.from(_noiseHistory),
colorScheme: scheme,
textTheme: tt,
),
child: const SizedBox.expand(),
),
child: const SizedBox.expand(),
),
),
const SizedBox(height: 8),
Text(
l10n.radioStats_chartCaption,
style: tt.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
);
},
+234 -89
View File
@@ -16,6 +16,7 @@ import '../models/contact.dart';
import '../l10n/contact_localization.dart';
import '../models/contact_group.dart';
import '../services/ui_view_state_service.dart';
import '../theme/mesh_theme.dart';
import '../utils/contact_search.dart';
import '../storage/contact_group_store.dart';
import '../utils/dialog_utils.dart';
@@ -24,12 +25,12 @@ import '../utils/emoji_utils.dart';
import '../utils/route_transitions.dart';
import '../widgets/list_filter_widget.dart';
import '../widgets/empty_state.dart';
import '../widgets/mesh_ui.dart';
import '../widgets/quick_switch_bar.dart';
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';
@@ -472,12 +473,13 @@ class _ContactsScreenState extends State<ContactsScreen>
}
void _showAddContactSheet(BuildContext context) {
showModalBottomSheet(
context: context,
showMeshSheet(
context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
BottomSheetHeader(title: context.l10n.contacts_title),
ListTile(
leading: const Icon(Icons.paste),
title: Text(context.l10n.contacts_addContactFromClipboard),
@@ -499,6 +501,7 @@ class _ContactsScreenState extends State<ContactsScreen>
);
},
),
const SizedBox(height: 8),
],
),
),
@@ -909,7 +912,8 @@ class _ContactsScreenState extends State<ContactsScreen>
final unreadCount = connector.getUnreadCountForContact(
contact,
);
return _ContactTile(
return _ContactTileEntrance(
index: index,
contact: contact,
pathHashByteWidth: connector.pathHashByteWidth,
lastSeen: _resolveLastSeen(contact),
@@ -1344,17 +1348,22 @@ class _ContactsScreenState extends State<ContactsScreen>
final isRoom = contact.type == advTypeRoom;
final isFavorite = contact.isFavorite;
showModalBottomSheet(
context: context,
showMeshSheet(
context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
BottomSheetHeader(
title: contact.name,
subtitle: contact.typeLabel(context.l10n),
),
if (isRepeater) ...[
ListTile(
leading: const Icon(Icons.radar, color: Colors.green),
leading: Icon(Icons.radar, color: MeshPalette.signal),
title: Text(context.l10n.contacts_ping),
onTap: () {
Navigator.pop(sheetContext);
final hw = context
.read<MeshCoreConnector>()
.pathHashByteWidth;
@@ -1375,7 +1384,7 @@ class _ContactsScreenState extends State<ContactsScreen>
},
),
ListTile(
leading: const Icon(Icons.cell_tower, color: Colors.orange),
leading: Icon(Icons.cell_tower, color: MeshPalette.warn),
title: Text(context.l10n.contacts_manageRepeater),
onTap: () {
Navigator.pop(sheetContext);
@@ -1384,9 +1393,10 @@ class _ContactsScreenState extends State<ContactsScreen>
),
] else if (isRoom) ...[
ListTile(
leading: const Icon(Icons.radar, color: Colors.green),
leading: Icon(Icons.radar, color: MeshPalette.signal),
title: Text(context.l10n.contacts_pathTrace),
onTap: () {
Navigator.pop(sheetContext);
final hw = context
.read<MeshCoreConnector>()
.pathHashByteWidth;
@@ -1409,7 +1419,7 @@ class _ContactsScreenState extends State<ContactsScreen>
},
),
ListTile(
leading: const Icon(Icons.room, color: Colors.blue),
leading: Icon(Icons.meeting_room, color: MeshPalette.blue),
title: Text(context.l10n.contacts_roomLogin),
onTap: () {
Navigator.pop(sheetContext);
@@ -1417,10 +1427,7 @@ class _ContactsScreenState extends State<ContactsScreen>
},
),
ListTile(
leading: const Icon(
Icons.room_preferences,
color: Colors.orange,
),
leading: Icon(Icons.room_preferences, color: MeshPalette.warn),
title: Text(context.l10n.room_management),
onTap: () {
Navigator.pop(sheetContext);
@@ -1434,9 +1441,10 @@ class _ContactsScreenState extends State<ContactsScreen>
] else ...[
if (contact.pathLength > 0)
ListTile(
leading: const Icon(Icons.radar, color: Colors.green),
leading: Icon(Icons.radar, color: MeshPalette.signal),
title: Text(context.l10n.contacts_chatTraceRoute),
onTap: () {
Navigator.pop(sheetContext);
final hw = context
.read<MeshCoreConnector>()
.pathHashByteWidth;
@@ -1460,7 +1468,7 @@ class _ContactsScreenState extends State<ContactsScreen>
ListTile(
leading: Icon(
isFavorite ? Icons.star : Icons.star_border,
color: Colors.amber[700],
color: MeshPalette.warn,
),
title: Text(
isFavorite
@@ -1505,6 +1513,7 @@ class _ContactsScreenState extends State<ContactsScreen>
_confirmDelete(context, connector, contact);
},
),
const SizedBox(height: 8),
],
),
),
@@ -1571,82 +1580,179 @@ class _ContactTile extends StatelessWidget {
required this.onLongPress,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onSecondaryTapUp: PlatformInfo.isDesktop ? (_) => onLongPress() : null,
child: ListTile(
leading: CircleAvatar(
backgroundColor: contactTypeColor(contact.type),
child: _buildContactAvatar(contact),
),
title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
contact.pathLabel(context.l10n, pathHashByteWidth: pathHashByteWidth),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Clamp text scaling in trailing section to prevent overflow while
// maintaining accessibility. Primary content (title/subtitle) scales normally.
trailing: MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
),
),
child: SizedBox(
width: 96,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (unreadCount > 0) ...[
UnreadBadge(count: unreadCount),
const SizedBox(height: 4),
],
Text(
_formatLastSeen(context, lastSeen),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isFavorite)
Icon(Icons.star, size: 14, color: Colors.amber[700]),
if (isFavorite && contact.hasLocation)
const SizedBox(width: 2),
if (contact.hasLocation)
Icon(
Icons.location_on,
size: 14,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
],
),
],
),
),
),
onTap: onTap,
onLongPress: onLongPress,
),
);
/// Node-type avatar color per design language.
Color _avatarColor() {
switch (contact.type) {
case advTypeRepeater:
return MeshPalette.warn;
case advTypeRoom:
return MeshPalette.magenta;
case advTypeSensor:
return const Color(0xFF4ACCC4); // teal
default:
return MeshPalette
.blue; // chat AvatarCircle handles deterministic hue
}
}
Widget _buildContactAvatar(Contact contact) {
final emoji = firstEmoji(contact.name);
if (emoji != null) {
return Text(emoji, style: const TextStyle(fontSize: 18));
/// Node-type avatar icon. Returns null for chat nodes so AvatarCircle shows initials.
IconData? _avatarIcon() {
switch (contact.type) {
case advTypeRepeater:
return Icons.cell_tower;
case advTypeRoom:
return Icons.meeting_room;
case advTypeSensor:
return Icons.sensors;
default:
return null; // chat uses initials
}
return Icon(contactTypeIcon(contact.type), color: Colors.white, size: 20);
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final emoji = firstEmoji(contact.name);
final isChat = contact.type == advTypeChat;
final pathLen = contact.pathBytesForDisplay.length;
final isDirect = contact.pathLength >= 0;
final hasPath = pathLen > 0 || contact.pathLength == 0;
return GestureDetector(
onSecondaryTapUp: PlatformInfo.isDesktop ? (_) => onLongPress() : null,
child: MeshCard(
onTap: onTap,
onLongPress: onLongPress,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
// Avatar
if (emoji != null)
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: scheme.surfaceContainerHigh,
border: Border.all(color: scheme.outlineVariant),
),
alignment: Alignment.center,
child: Text(emoji, style: const TextStyle(fontSize: 20)),
)
else
AvatarCircle(
name: contact.name,
size: 42,
color: isChat ? null : _avatarColor(),
icon: _avatarIcon(),
),
const SizedBox(width: 12),
// Main content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Name row + route chip
Row(
children: [
Expanded(
child: Text(
contact.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: unreadCount > 0
? FontWeight.w700
: FontWeight.w500,
fontSize: 15,
color: scheme.onSurface,
),
),
),
if (isFavorite) ...[
const SizedBox(width: 4),
Icon(Icons.star, size: 13, color: MeshPalette.warn),
],
if (contact.hasLocation) ...[
const SizedBox(width: 4),
Icon(
Icons.location_on,
size: 13,
color: scheme.onSurfaceVariant.withValues(
alpha: 0.55,
),
),
],
],
),
const SizedBox(height: 3),
// Path / subtitle row
Row(
children: [
Expanded(
child: Text(
contact.pathLabel(
context.l10n,
pathHashByteWidth: pathHashByteWidth,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: scheme.onSurfaceVariant,
),
),
),
if (hasPath) ...[
const SizedBox(width: 6),
RouteChip(
isDirect: isDirect,
hops: isDirect ? contact.pathLength : null,
),
],
],
),
],
),
),
const SizedBox(width: 10),
// Trailing: time + unread badge
// Clamp text scale to prevent overflow in trailing section.
MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
if (unreadCount > 0) ...[
UnreadBadge(count: unreadCount),
const SizedBox(height: 4),
],
Text(
_formatLastSeen(context, lastSeen),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: MeshTheme.mono(
fontSize: 11,
color: unreadCount > 0
? MeshPalette.blue
: scheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
);
}
String _formatLastSeen(BuildContext context, DateTime lastSeen) {
@@ -1671,3 +1777,42 @@ class _ContactTile extends StatelessWidget {
: context.l10n.contacts_lastSeenDaysAgo(days);
}
}
// Wrap each contact tile with staggered entrance.
class _ContactTileEntrance extends StatelessWidget {
final int index;
final Contact contact;
final int pathHashByteWidth;
final DateTime lastSeen;
final int unreadCount;
final bool isFavorite;
final VoidCallback onTap;
final VoidCallback onLongPress;
const _ContactTileEntrance({
required this.index,
required this.contact,
required this.pathHashByteWidth,
required this.lastSeen,
required this.unreadCount,
required this.isFavorite,
required this.onTap,
required this.onLongPress,
});
@override
Widget build(BuildContext context) {
return ListEntrance(
index: index,
child: _ContactTile(
contact: contact,
pathHashByteWidth: pathHashByteWidth,
lastSeen: lastSeen,
unreadCount: unreadCount,
isFavorite: isFavorite,
onTap: onTap,
onLongPress: onLongPress,
),
);
}
}
+207 -109
View File
@@ -7,12 +7,14 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart';
import '../l10n/contact_localization.dart';
import '../models/contact.dart';
import '../theme/mesh_theme.dart';
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 '../widgets/mesh_ui.dart';
import '../helpers/snack_bar_builder.dart';
enum DiscoverySortOption { lastSeen, name, type }
@@ -47,6 +49,34 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
: contact.lastSeen;
}
/// Node-type avatar color per design language.
Color _avatarColor(int type) {
switch (type) {
case advTypeRepeater:
return MeshPalette.warn;
case advTypeRoom:
return MeshPalette.magenta;
case advTypeSensor:
return const Color(0xFF4ACCC4); // teal
default:
return MeshPalette.blue;
}
}
/// Node-type avatar icon; null = show initials for chat nodes.
IconData? _avatarIcon(int type) {
switch (type) {
case advTypeRepeater:
return Icons.cell_tower;
case advTypeRoom:
return Icons.meeting_room;
case advTypeSensor:
return Icons.sensors;
default:
return null;
}
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
@@ -93,121 +123,185 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
children: [
_buildFilters(filteredAndSorted, connector),
Expanded(
child: discoveredContacts.isEmpty
? Center(child: Text(l10n.contacts_noContacts))
: filteredAndSorted.isEmpty
? Center(child: Text(l10n.discoveredContacts_noMatching))
: ListView.builder(
itemCount: filteredAndSorted.length,
itemBuilder: (context, index) {
final contact = filteredAndSorted[index];
final tile = ListTile(
leading: CircleAvatar(
backgroundColor: contactTypeColor(contact.type),
child: Icon(
contactTypeIcon(contact.type),
color: Colors.white,
size: 20,
),
),
title: Text(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
child: discoveredContacts.isEmpty
? Center(
key: const ValueKey('empty_all'),
child: Text(l10n.contacts_noContacts),
)
: filteredAndSorted.isEmpty
? Center(
key: const ValueKey('empty_filtered'),
child: Text(l10n.discoveredContacts_noMatching),
)
: ListView.builder(
key: const ValueKey('list'),
padding: const EdgeInsets.only(bottom: 24),
itemCount: filteredAndSorted.length,
itemBuilder: (context, index) {
final contact = filteredAndSorted[index];
final tile = _buildDiscoveryTile(
context,
contact,
connector,
index,
);
if (PlatformInfo.isDesktop) {
return GestureDetector(
onSecondaryTapUp: (_) =>
_showContactContextMenu(contact, connector),
child: tile,
);
}
return tile;
},
),
),
),
],
),
);
}
Widget _buildDiscoveryTile(
BuildContext context,
Contact contact,
MeshCoreConnector connector,
int index,
) {
final scheme = Theme.of(context).colorScheme;
final isChat = contact.type == advTypeChat;
return ListEntrance(
index: index,
child: MeshCard(
onTap: () async {
try {
final imported = await connector.importDiscoveredContact(contact);
if (!context.mounted) return;
if (!imported) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactImportFailed),
);
return;
}
showDismissibleSnackBar(
context,
content: Text(context.l10n.discoveredContacts_contactAdded),
action: SnackBarAction(
label: context.l10n.common_undo,
onPressed: () => connector.removeContact(contact),
),
);
} catch (_) {
if (!context.mounted) return;
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactImportFailed),
);
}
},
onLongPress: () => _showContactContextMenu(contact, connector),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
AvatarCircle(
name: contact.name,
size: 42,
color: isChat ? null : _avatarColor(contact.type),
icon: _avatarIcon(contact.type),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Name + type chip
Row(
children: [
Expanded(
child: Text(
contact.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 15,
),
),
subtitle: Text(
),
const SizedBox(width: 6),
StatusChip(
label: contact.typeLabel(context.l10n).toUpperCase(),
color: _avatarColor(contact.type),
icon: _avatarIcon(contact.type),
),
],
),
const SizedBox(height: 3),
// Short pub key
Row(
children: [
Expanded(
child: Text(
contact.shortPubKeyHex,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Clamp text scaling in trailing section to prevent overflow while
// maintaining accessibility. Primary content (title/subtitle) scales normally.
trailing: MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(
MediaQuery.textScalerOf(
context,
).scale(1.0).clamp(1.0, 1.3),
),
),
child: SizedBox(
width: 120,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatLastSeen(
context,
_resolveLastSeen(contact),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (contact.hasLocation)
Icon(
Icons.location_on,
size: 14,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.6),
),
if (contact.rawPacket != null)
const SizedBox(width: 2),
if (contact.rawPacket != null)
Icon(
Icons.cell_tower,
size: 14,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.6),
),
],
),
],
),
style: MeshTheme.mono(
fontSize: 11,
color: scheme.onSurfaceVariant,
),
),
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),
);
if (PlatformInfo.isDesktop) {
return GestureDetector(
onSecondaryTapUp: (_) =>
_showContactContextMenu(contact, connector),
child: tile,
);
}
return tile;
},
),
if (contact.hasLocation) ...[
const SizedBox(width: 6),
Icon(
Icons.location_on,
size: 13,
color: scheme.onSurfaceVariant.withValues(
alpha: 0.55,
),
),
],
if (contact.rawPacket != null) ...[
const SizedBox(width: 4),
Icon(
Icons.cell_tower,
size: 13,
color: scheme.onSurfaceVariant.withValues(
alpha: 0.55,
),
),
],
],
),
),
],
],
),
),
const SizedBox(width: 10),
// Last seen time
MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
),
),
child: Text(
_formatLastSeen(context, _resolveLastSeen(contact)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: MeshTheme.mono(
fontSize: 11,
color: scheme.onSurfaceVariant,
),
),
),
],
),
),
);
}
@@ -216,15 +310,18 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
Contact contact,
MeshCoreConnector connector,
) async {
final action = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
final action = await showMeshSheet<String>(
context,
builder: (sheetContext) {
final l10n = context.l10n;
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
BottomSheetHeader(
title: contact.name,
subtitle: contact.typeLabel(l10n),
),
ListTile(
leading: const Icon(Icons.copy),
title: Text(l10n.discoveredContacts_copyContact),
@@ -235,6 +332,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
title: Text(l10n.discoveredContacts_deleteContact),
onTap: () => Navigator.of(sheetContext).pop('delete_contact'),
),
const SizedBox(height: 8),
],
),
);
File diff suppressed because it is too large Load Diff
+166 -128
View File
@@ -10,6 +10,9 @@ import '../services/app_settings_service.dart';
import '../services/map_tile_cache_service.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
import '../widgets/themed_map_tile_layer.dart';
class MapCacheScreen extends StatefulWidget {
const MapCacheScreen({super.key});
@@ -76,27 +79,34 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
return Positioned(
top: 12,
left: 12,
child: Card(
elevation: 4,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.add),
tooltip: context.l10n.map_zoomIn,
onPressed: () => _zoomMapBy(1),
),
IconButton(
icon: const Icon(Icons.remove),
tooltip: context.l10n.map_zoomOut,
onPressed: () => _zoomMapBy(-1),
),
IconButton(
icon: const Icon(Icons.my_location),
tooltip: context.l10n.map_centerMap,
onPressed: _resetMapView,
),
],
child: DecoratedBox(
decoration: BoxDecoration(
color: MeshPalette.bg1.withValues(alpha: 0.90),
borderRadius: BorderRadius.circular(MeshRadii.md),
border: Border.all(color: MeshPalette.line2),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(MeshRadii.md),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.add),
tooltip: context.l10n.map_zoomIn,
onPressed: () => _zoomMapBy(1),
),
IconButton(
icon: const Icon(Icons.remove),
tooltip: context.l10n.map_zoomOut,
onPressed: () => _zoomMapBy(-1),
),
IconButton(
icon: const Icon(Icons.my_location),
tooltip: context.l10n.map_centerMap,
onPressed: _resetMapView,
),
],
),
),
),
);
@@ -281,6 +291,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
final tileCache = context.read<MapTileCacheService>();
final selectedBounds = _selectedBounds;
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
final progressValue = _estimatedTiles == 0
? 0.0
@@ -318,13 +329,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
),
),
children: [
TileLayer(
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName:
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
ThemedMapTileLayer(tileCache: tileCache),
if (selectedBounds != null)
PolygonLayer(
polygons: [
@@ -342,14 +347,25 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
Positioned(
top: 12,
right: 12,
child: Card(
child: DecoratedBox(
decoration: BoxDecoration(
color: MeshPalette.bg1.withValues(alpha: 0.93),
borderRadius: BorderRadius.circular(MeshRadii.md),
border: Border.all(color: MeshPalette.line2),
),
child: Padding(
padding: const EdgeInsets.all(8),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
child: Text(
selectedBounds == null
? l10n.mapCache_noAreaSelected
: _formatBounds(selectedBounds, l10n),
style: const TextStyle(fontSize: 12),
style: MeshTheme.mono(
fontSize: 11,
color: MeshPalette.ink2,
),
),
),
),
@@ -359,111 +375,133 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.mapCache_cacheArea,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerLow,
border: Border(top: BorderSide(color: scheme.outlineVariant)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SectionHeader(
l10n.mapCache_cacheArea,
padding: const EdgeInsets.fromLTRB(0, 12, 0, 8),
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.crop_free),
label: Text(l10n.mapCache_useCurrentView),
onPressed: _isDownloading ? null : _setBoundsFromView,
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.crop_free),
label: Text(l10n.mapCache_useCurrentView),
onPressed: _isDownloading
? null
: _setBoundsFromView,
),
),
),
const SizedBox(width: 12),
TextButton(
onPressed: _isDownloading || selectedBounds == null
? null
: _clearBounds,
child: Text(l10n.common_clear),
),
],
),
const SizedBox(height: 12),
Text(
l10n.mapCache_zoomRange,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
RangeSlider(
values: RangeValues(
_minZoom.toDouble(),
_maxZoom.toDouble(),
),
min: 3,
max: 18,
divisions: 15,
labels: RangeLabels('$_minZoom', '$_maxZoom'),
onChanged: _isDownloading
? null
: (values) {
setState(() {
_minZoom = values.start.round();
_maxZoom = values.end.round();
});
},
onChangeEnd: _isDownloading
? null
: (_) {
_saveZoomRange();
},
),
Text(l10n.mapCache_estimatedTiles(_estimatedTiles)),
if (_isDownloading) ...[
const SizedBox(height: 8),
LinearProgressIndicator(value: progressValue),
const SizedBox(height: 4),
Text(
l10n.mapCache_downloadedTiles(
_completedTiles,
_estimatedTiles,
),
),
],
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.download),
label: Text(l10n.mapCache_downloadTilesButton),
const SizedBox(width: 12),
TextButton(
onPressed: _isDownloading || selectedBounds == null
? null
: _startDownload,
: _clearBounds,
child: Text(l10n.common_clear),
),
],
),
const SizedBox(height: 12),
SectionHeader(
l10n.mapCache_zoomRange,
padding: const EdgeInsets.fromLTRB(0, 8, 0, 0),
),
RangeSlider(
values: RangeValues(
_minZoom.toDouble(),
_maxZoom.toDouble(),
),
const SizedBox(width: 12),
OutlinedButton(
onPressed: _isDownloading ? null : _clearCache,
child: Text(l10n.mapCache_clearCacheButton),
),
],
),
if (_failedTiles > 0 && !_isDownloading)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
l10n.mapCache_failedDownloads(_failedTiles),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
min: 3,
max: 18,
divisions: 15,
labels: RangeLabels('$_minZoom', '$_maxZoom'),
onChanged: _isDownloading
? null
: (values) {
setState(() {
_minZoom = values.start.round();
_maxZoom = values.end.round();
});
},
onChangeEnd: _isDownloading
? null
: (_) {
_saveZoomRange();
},
),
Text(
l10n.mapCache_estimatedTiles(_estimatedTiles),
style: MeshTheme.mono(
fontSize: 12,
color: scheme.onSurfaceVariant,
),
),
],
if (_isDownloading) ...[
const SizedBox(height: 8),
LinearProgressIndicator(
value: progressValue,
color: MeshPalette.blue,
backgroundColor: scheme.surfaceContainerHighest,
),
const SizedBox(height: 4),
Text(
l10n.mapCache_downloadedTiles(
_completedTiles,
_estimatedTiles,
),
style: MeshTheme.mono(
fontSize: 12,
color: scheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.download),
label: Text(l10n.mapCache_downloadTilesButton),
onPressed: _isDownloading || selectedBounds == null
? null
: _startDownload,
),
),
const SizedBox(width: 12),
OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: MeshPalette.alert,
side: const BorderSide(
color: MeshPalette.alertLine,
),
),
onPressed: _isDownloading ? null : _clearCache,
child: Text(l10n.mapCache_clearCacheButton),
),
],
),
if (_failedTiles > 0 && !_isDownloading)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
l10n.mapCache_failedDownloads(_failedTiles),
style: MeshTheme.mono(
fontSize: 12,
color: MeshPalette.alert,
),
),
),
],
),
),
),
),
+2099 -698
View File
File diff suppressed because it is too large Load Diff
+86 -71
View File
@@ -9,9 +9,10 @@ import '../models/path_selection.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../services/repeater_command_service.dart';
import '../theme/mesh_theme.dart';
import '../widgets/empty_state.dart';
import '../widgets/mesh_ui.dart';
import '../widgets/routing_sheet.dart';
import '../widgets/snr_indicator.dart';
import '../helpers/snack_bar_builder.dart';
class NeighborsScreen extends StatefulWidget {
@@ -321,7 +322,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
child: RefreshIndicator(
onRefresh: _loadNeighbors,
child: ListView(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
if (!_isLoaded &&
!_hasData &&
@@ -330,9 +331,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
if (_isLoaded ||
_hasData &&
!(_parsedNeighbors == null || _parsedNeighbors!.isEmpty))
_buildNeighborsInfoCard(
"${l10n.repeater_neighbors} - $_neighborCount",
),
_buildNeighborsList(connector),
],
),
),
@@ -340,81 +339,97 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
);
}
Widget _buildNeighborsInfoCard(String title) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
Widget _buildNeighborsList(MeshCoreConnector connector) {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionHeader(
'${l10n.repeater_neighbors}$_neighborCount',
padding: const EdgeInsets.fromLTRB(4, 8, 4, 10),
),
for (var i = 0; i < _parsedNeighbors!.length; i++)
ListEntrance(
index: i,
child: _buildNeighborRow(_parsedNeighbors![i], connector.currentSf),
),
],
);
}
Widget _buildNeighborRow(Map<String, dynamic> data, int? spreadingFactor) {
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final Contact? contact = data['contact'] as Contact?;
final double snr = data['snr'] as double;
final int lastHeardSeconds = data['lastHeard'] as int;
final name = contact != null
? contact.name
: l10n.neighbors_unknownContact(
'<${pubKeyToHex(data['publicKey'] as Uint8List)}>',
);
final snrColor = MeshTheme.snrColor(snr, blocked: false);
final heardLabel = l10n.neighbors_heardAgo(
fmtDuration(lastHeardSeconds + 0.0),
);
return MeshCard(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
margin: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
AvatarCircle(
name: name,
size: 40,
color: contact != null ? MeshPalette.warn : scheme.onSurfaceVariant,
icon: contact != null ? Icons.cell_tower : Icons.device_unknown,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.info_outline,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
Text(
title,
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
fontWeight: FontWeight.w500,
fontSize: 15,
),
),
const SizedBox(height: 2),
Text(
heardLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
const Divider(),
for (final entry in _parsedNeighbors!.asMap().entries)
_buildInfoRow(
entry.value['contact'] != null
? entry.value['contact'].name
: context.l10n.neighbors_unknownContact(
"<${pubKeyToHex(entry.value['publicKey'])}>",
),
context.l10n.neighbors_heardAgo(
fmtDuration(entry.value['lastHeard'] + 0.0),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
SignalBars(snr: snr, height: 16),
const SizedBox(height: 4),
Text(
'${snr.toStringAsFixed(1)} dB',
style: MeshTheme.mono(
fontSize: 11,
fontWeight: FontWeight.w600,
color: snrColor,
),
entry.value['snr'],
connector.currentSf,
),
],
),
),
);
}
Widget _buildInfoRow(
String label,
String value,
double snr,
int? spreadingFactor,
) {
final snrUi = snrUiFromSNR(snr, spreadingFactor);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ListTile(
contentPadding: EdgeInsets.zero,
title: Text(
label,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(value),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(snrUi.icon, color: snrUi.color, size: 18.0),
Text(
snrUi.text,
style: TextStyle(fontSize: 10, color: snrUi.color),
),
],
),
),
],
),
],
),
File diff suppressed because it is too large Load Diff
+217 -203
View File
@@ -1,11 +1,12 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../theme/mesh_theme.dart';
import '../widgets/debug_frame_viewer.dart';
import '../services/repeater_command_service.dart';
import '../widgets/routing_sheet.dart';
@@ -34,7 +35,6 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
StreamSubscription<Uint8List>? _frameSubscription;
RepeaterCommandService? _commandService;
// Common commands for quick access
late final List<Map<String, String>> _quickCommands = [
{'labelKey': 'advertise', 'command': 'advert'},
{'labelKey': 'getName', 'command': 'get name'},
@@ -67,12 +67,8 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
void _setupMessageListener() {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Listen for incoming text messages from the repeater
_frameSubscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return;
// Check if it's a text message response
if (frame[0] == respCodeContactMsgRecv ||
frame[0] == respCodeContactMsgRecvV3) {
_handleTextMessageResponse(frame);
@@ -102,12 +98,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
final parsed = parseContactMessageText(frame);
if (parsed == null) return;
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
// Notify command service of response (for retry handling)
_commandService?.handleResponse(widget.repeater, parsed.text);
// Note: The command service will handle the response via the Future
// We don't need to add it to history here anymore as _sendCommand will do it
}
bool _matchesRepeaterPrefix(Uint8List prefix) {
@@ -131,7 +122,6 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
});
});
// Show debug info if requested
if (showDebug && mounted) {
final frame = buildSendCliCommandFrame(
widget.repeater.publicKey,
@@ -144,7 +134,6 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
);
}
// Send CLI command to repeater with retry
try {
if (_commandService != null) {
final connector = Provider.of<MeshCoreConnector>(
@@ -157,7 +146,6 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
command,
retries: 1,
);
if (mounted) {
setState(() {
_commandHistory.add({
@@ -184,7 +172,6 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
_historyIndex = -1;
_commandFocusNode.requestFocus();
// Auto-scroll to bottom
Future.delayed(const Duration(milliseconds: 100), () {
if (_scrollController.hasClients) {
_scrollController.animateTo(
@@ -239,36 +226,46 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
});
}
String _quickCommandLabel(String key) {
final l10n = context.l10n;
switch (key) {
case 'getName':
return l10n.repeater_cliQuickGetName;
case 'getRadio':
return l10n.repeater_cliQuickGetRadio;
case 'getTx':
return l10n.repeater_cliQuickGetTx;
case 'neighbors':
return l10n.repeater_cliQuickNeighbors;
case 'version':
return l10n.repeater_cliQuickVersion;
case 'advertise':
return l10n.repeater_cliQuickAdvertise;
case 'clock':
return l10n.repeater_cliQuickClock;
case 'clock sync':
return l10n.repeater_cliQuickClockSync;
case 'discovery':
return l10n.repeater_cliQuickDiscovery;
default:
return key;
}
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
return Scaffold(
backgroundColor: MeshPalette.bg,
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
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,
backgroundColor: MeshPalette.bg1,
title: Text(l10n.repeater_cliTitle),
centerTitle: true,
actions: [
IconButton(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
@@ -317,94 +314,172 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
),
body: Column(
children: [
_buildQuickCommandsBar(),
const Divider(height: 1),
// Quick commands bar
Container(
color: MeshPalette.bg1,
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _quickCommands.map((cmd) {
final label = _quickCommandLabel(cmd['labelKey']!);
return Padding(
padding: const EdgeInsets.only(right: 6),
child: ActionChip(
label: Text(
label,
style: MeshTheme.mono(
fontSize: 11,
fontWeight: FontWeight.w600,
color: MeshPalette.blue,
),
),
backgroundColor: MeshPalette.blueBg,
side: const BorderSide(color: MeshPalette.blueLine),
visualDensity: VisualDensity.compact,
onPressed: () => _useQuickCommand(cmd['command']!),
),
);
}).toList(),
),
),
),
Divider(height: 1, color: MeshPalette.line),
// Output area
Expanded(
child: _commandHistory.isEmpty
? _buildEmptyState()
: _buildCommandHistory(),
),
const Divider(height: 1),
_buildCommandInput(),
Divider(height: 1, color: MeshPalette.line),
// Command input
Container(
color: MeshPalette.bg1,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
child: SafeArea(
child: Row(
children: [
IconButton(
icon: Icon(
Icons.arrow_upward,
size: 18,
color: scheme.onSurfaceVariant,
),
tooltip: l10n.repeater_previousCommand,
onPressed: () => _navigateHistory(true),
visualDensity: VisualDensity.compact,
),
IconButton(
icon: Icon(
Icons.arrow_downward,
size: 18,
color: scheme.onSurfaceVariant,
),
tooltip: l10n.repeater_nextCommand,
onPressed: () => _navigateHistory(false),
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 4),
Expanded(
child: TextField(
controller: _commandController,
focusNode: _commandFocusNode,
style: MeshTheme.mono(
fontSize: 13,
color: MeshPalette.ink,
),
decoration: InputDecoration(
hintText: context.l10n.repeater_enterCommandHint,
hintStyle: MeshTheme.mono(
fontSize: 13,
color: MeshPalette.ink4,
),
prefixText: '> ',
prefixStyle: MeshTheme.mono(
fontSize: 13,
color: MeshPalette.blue,
fontWeight: FontWeight.w700,
),
filled: true,
fillColor: MeshPalette.bg2,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(MeshRadii.pill),
borderSide: const BorderSide(
color: MeshPalette.line2,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(MeshRadii.pill),
borderSide: const BorderSide(
color: MeshPalette.line2,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(MeshRadii.pill),
borderSide: const BorderSide(
color: MeshPalette.blue,
width: 1.5,
),
),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendCommand(),
),
),
const SizedBox(width: 6),
Material(
color: MeshPalette.blue.withValues(alpha: 0.15),
shape: const CircleBorder(
side: BorderSide(color: MeshPalette.blueLine),
),
child: InkWell(
customBorder: const CircleBorder(),
onTap: () {
HapticFeedback.lightImpact();
_sendCommand();
},
child: const Padding(
padding: EdgeInsets.all(10),
child: Icon(
Icons.send,
size: 18,
color: MeshPalette.blue,
),
),
),
),
],
),
),
),
],
),
);
}
Widget _buildQuickCommandsBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _quickCommands.map((cmd) {
final label = _quickCommandLabel(cmd['labelKey']!);
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ActionChip(
label: Text(label),
onPressed: () => _useQuickCommand(cmd['command']!),
avatar: const Icon(Icons.play_arrow, size: 16),
),
);
}).toList(),
),
),
);
}
String _quickCommandLabel(String key) {
final l10n = context.l10n;
switch (key) {
case 'getName':
return l10n.repeater_cliQuickGetName;
case 'getRadio':
return l10n.repeater_cliQuickGetRadio;
case 'getTx':
return l10n.repeater_cliQuickGetTx;
case 'neighbors':
return l10n.repeater_cliQuickNeighbors;
case 'version':
return l10n.repeater_cliQuickVersion;
case 'advertise':
return l10n.repeater_cliQuickAdvertise;
case 'clock':
return l10n.repeater_cliQuickClock;
case 'clock sync':
return l10n.repeater_cliQuickClockSync;
case 'discovery':
return l10n.repeater_cliQuickDiscovery;
default:
return key;
}
}
Widget _buildEmptyState() {
final l10n = context.l10n;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.terminal,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
const Icon(Icons.terminal, size: 48, color: MeshPalette.ink4),
const SizedBox(height: 12),
Text(
l10n.repeater_noCommandsSent,
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
style: MeshTheme.mono(fontSize: 13, color: MeshPalette.ink3),
),
const SizedBox(height: 8),
const SizedBox(height: 4),
Text(
l10n.repeater_typeCommandOrUseQuick,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
style: const TextStyle(fontSize: 12, color: MeshPalette.ink4),
),
],
),
@@ -414,49 +489,37 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
Widget _buildCommandHistory() {
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: _commandHistory.length,
itemBuilder: (context, index) {
final entry = _commandHistory[index];
final isCommand = entry['type'] == 'command';
return Padding(
padding: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.only(bottom: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: isCommand
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Icon(
isCommand ? Icons.chevron_right : Icons.arrow_back,
size: 16,
color: isCommand
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSecondaryContainer,
// Gutter prefix
SizedBox(
width: 20,
child: Text(
isCommand ? '>' : ' ',
style: MeshTheme.mono(
fontSize: 12,
fontWeight: FontWeight.w700,
color: isCommand ? MeshPalette.blue : MeshPalette.ink3,
),
),
),
const SizedBox(width: 12),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(
entry['text']!,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 13,
color: isCommand
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
),
],
child: SelectableText(
entry['text']!,
style: MeshTheme.mono(
fontSize: 12.5,
color: isCommand ? MeshPalette.blue : MeshPalette.ink,
),
),
),
],
@@ -466,54 +529,6 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
);
}
Widget _buildCommandInput() {
final l10n = context.l10n;
return Container(
padding: const EdgeInsets.all(12),
color: Theme.of(context).colorScheme.surface,
child: SafeArea(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_upward, size: 20),
tooltip: l10n.repeater_previousCommand,
onPressed: () => _navigateHistory(true),
),
IconButton(
icon: const Icon(Icons.arrow_downward, size: 20),
tooltip: l10n.repeater_nextCommand,
onPressed: () => _navigateHistory(false),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _commandController,
focusNode: _commandFocusNode,
decoration: InputDecoration(
hintText: l10n.repeater_enterCommandHint,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
prefixText: '> ',
),
style: const TextStyle(fontFamily: 'monospace'),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendCommand(),
),
),
const SizedBox(width: 8),
IconButton.filled(
icon: const Icon(Icons.send),
onPressed: _sendCommand,
),
],
),
),
);
}
void _applyHelpCommand(String command) {
_commandController.text = command;
_commandController.selection = TextSelection.fromPosition(
@@ -1134,16 +1149,20 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
List<_CommandHelpEntry> commands, {
String? note,
}) {
final scheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
if (note != null) ...[
const SizedBox(height: 6),
Text(note, style: const TextStyle(fontSize: 12)),
const SizedBox(height: 4),
Text(
note,
style: TextStyle(fontSize: 11, color: scheme.onSurfaceVariant),
),
],
const SizedBox(height: 8),
...commands.map((entry) => _buildHelpCommandCard(context, entry)),
@@ -1152,39 +1171,35 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
}
Widget _buildHelpCommandCard(BuildContext context, _CommandHelpEntry entry) {
final colorScheme = Theme.of(context).colorScheme;
final scheme = Theme.of(context).colorScheme;
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 8),
color: colorScheme.surfaceContainerHighest,
margin: const EdgeInsets.only(bottom: 6),
color: scheme.surfaceContainerHighest,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(MeshRadii.sm),
side: BorderSide(color: scheme.outlineVariant),
),
child: InkWell(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(MeshRadii.sm),
onTap: () => _applyHelpCommand(entry.command),
child: Padding(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.command,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 13,
fontWeight: FontWeight.bold,
color: colorScheme.onSurfaceVariant,
style: MeshTheme.mono(
fontSize: 12,
fontWeight: FontWeight.w600,
color: MeshPalette.blue,
),
),
const SizedBox(height: 6),
const SizedBox(height: 4),
Text(
entry.description,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant),
),
],
),
@@ -1197,6 +1212,5 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
class _CommandHelpEntry {
final String command;
final String description;
const _CommandHelpEntry({required this.command, required this.description});
}
+211 -232
View File
@@ -1,10 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../l10n/contact_localization.dart';
import '../services/app_settings_service.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
import 'repeater_status_screen.dart';
import 'repeater_cli_screen.dart';
import 'repeater_settings_screen.dart';
@@ -26,189 +29,157 @@ class RepeaterHubScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final settingsService = context.watch<AppSettingsService>();
final chemistry = settingsService.batteryChemistryForRepeater(
repeater.publicKeyHex,
);
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (isAdmin)
Text(
repeater.type == advTypeRepeater
? l10n.repeater_management
: l10n.room_management,
),
if (!isAdmin)
Text(
repeater.type == advTypeRepeater
? l10n.repeater_guest
: l10n.room_guest,
),
Text(
repeater.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
),
],
title: Text(
repeater.type == advTypeRepeater
? (isAdmin ? l10n.repeater_management : l10n.repeater_guest)
: (isAdmin ? l10n.room_management : l10n.room_guest),
),
centerTitle: false,
centerTitle: true,
),
body: SafeArea(
top: false,
child: ListView(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.only(bottom: 24),
children: [
// Repeater info card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
// Identity card
Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
child: MeshCard(
margin: EdgeInsets.zero,
padding: const EdgeInsets.all(20),
child: Row(
children: [
CircleAvatar(
radius: 40,
backgroundColor: Theme.of(
context,
).colorScheme.tertiaryContainer,
child: Icon(
Icons.cell_tower,
size: 40,
color: Theme.of(
context,
).colorScheme.onTertiaryContainer,
),
AvatarCircle(
name: repeater.name,
size: 52,
color: MeshPalette.warn,
icon: Icons.cell_tower,
),
const SizedBox(height: 16),
Text(
repeater.name,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
repeater.shortPubKeyHex,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
repeater.pathLabel(context.l10n),
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
if (repeater.hasLocation) ...[
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.center,
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.location_on,
size: 14,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'${repeater.latitude?.toStringAsFixed(4)}, ${repeater.longitude?.toStringAsFixed(4)}',
style: TextStyle(
fontSize: 12,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
repeater.name,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
repeater.shortPubKeyHex,
style: MeshTheme.mono(
fontSize: 11,
color: scheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
repeater.pathLabel(l10n),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: scheme.onSurfaceVariant),
),
if (repeater.hasLocation) ...[
const SizedBox(height: 2),
Row(
children: [
Icon(
Icons.location_on,
size: 12,
color: scheme.onSurfaceVariant,
),
const SizedBox(width: 3),
Expanded(
child: Text(
'${repeater.latitude?.toStringAsFixed(4)}, '
'${repeater.longitude?.toStringAsFixed(4)}',
style: MeshTheme.mono(
fontSize: 10,
color: scheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
],
),
],
),
StatusChip(
label: isAdmin ? 'ADMIN' : 'GUEST',
color: isAdmin
? MeshPalette.blue
: scheme.onSurfaceVariant,
),
],
),
),
),
const SizedBox(height: 24),
if (isAdmin)
Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.battery_full),
const SizedBox(width: 10),
Expanded(
child: Text(
l10n.appSettings_batteryChemistry,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: chemistry,
isExpanded: true,
decoration: const InputDecoration(
border: UnderlineInputBorder(),
isDense: true,
),
onChanged: (value) {
if (value == null) return;
settingsService.setBatteryChemistryForRepeater(
repeater.publicKeyHex,
value,
);
},
items: [
DropdownMenuItem(
value: 'nmc',
child: Text(l10n.appSettings_batteryNmc),
),
DropdownMenuItem(
value: 'lifepo4',
child: Text(l10n.appSettings_batteryLifepo4),
),
DropdownMenuItem(
value: 'lipo',
child: Text(l10n.appSettings_batteryLipo),
),
],
),
],
// Battery chemistry (admin only)
if (isAdmin) ...[
SectionHeader(l10n.appSettings_batteryChemistry),
MeshCard(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
padding: const EdgeInsets.fromLTRB(14, 10, 14, 14),
child: DropdownButtonFormField<String>(
initialValue: chemistry,
isExpanded: true,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.battery_full, size: 18),
labelText: l10n.appSettings_batteryChemistry,
),
onChanged: (value) {
if (value == null) return;
settingsService.setBatteryChemistryForRepeater(
repeater.publicKeyHex,
value,
);
},
items: [
DropdownMenuItem(
value: 'nmc',
child: Text(l10n.appSettings_batteryNmc),
),
DropdownMenuItem(
value: 'lifepo4',
child: Text(l10n.appSettings_batteryLifepo4),
),
DropdownMenuItem(
value: 'lipo',
child: Text(l10n.appSettings_batteryLipo),
),
],
),
),
const SizedBox(height: 24),
Text(
],
// Tools
SectionHeader(
isAdmin
? l10n.repeater_managementTools
: l10n.repeater_guestTools,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// Status button
_buildManagementCard(
context,
_HubActionTile(
index: 0,
icon: Icons.analytics,
title: l10n.repeater_status,
subtitle: l10n.repeater_statusSubtitle,
color: Theme.of(context).colorScheme.primary,
accentColor: MeshPalette.blue,
onTap: () {
HapticFeedback.selectionClick();
Navigator.push(
context,
MaterialPageRoute(
@@ -220,15 +191,15 @@ class RepeaterHubScreen extends StatelessWidget {
);
},
),
const SizedBox(height: 16),
// Telemetry button
_buildManagementCard(
context,
_HubActionTile(
index: 1,
icon: Icons.bar_chart_sharp,
title: l10n.repeater_telemetry,
subtitle: l10n.repeater_telemetrySubtitle,
color: Theme.of(context).colorScheme.secondary,
accentColor: MeshPalette.magenta,
onTap: () {
HapticFeedback.selectionClick();
Navigator.push(
context,
MaterialPageRoute(
@@ -237,16 +208,34 @@ class RepeaterHubScreen extends StatelessWidget {
);
},
),
if (isAdmin) const SizedBox(height: 12),
// CLI button
if (isAdmin)
_buildManagementCard(
context,
_HubActionTile(
index: 2,
icon: Icons.group,
title: l10n.repeater_neighbors,
subtitle: l10n.repeater_neighborsSubtitle,
accentColor: MeshPalette.signal,
onTap: () {
HapticFeedback.selectionClick();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
NeighborsScreen(repeater: repeater, password: password),
),
);
},
),
if (isAdmin) ...[
_HubActionTile(
index: 3,
icon: Icons.terminal,
title: l10n.repeater_cli,
subtitle: l10n.repeater_cliSubtitle,
color: Theme.of(context).colorScheme.tertiary,
accentColor: MeshPalette.warn,
onTap: () {
HapticFeedback.selectionClick();
Navigator.push(
context,
MaterialPageRoute(
@@ -258,34 +247,14 @@ class RepeaterHubScreen extends StatelessWidget {
);
},
),
const SizedBox(height: 12),
// Neighbors button
_buildManagementCard(
context,
icon: Icons.group,
title: l10n.repeater_neighbors,
subtitle: l10n.repeater_neighborsSubtitle,
color: Theme.of(context).colorScheme.tertiary,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
NeighborsScreen(repeater: repeater, password: password),
),
);
},
),
if (isAdmin) const SizedBox(height: 12),
// Settings button
if (isAdmin)
_buildManagementCard(
context,
_HubActionTile(
index: 4,
icon: Icons.settings,
title: l10n.repeater_settings,
subtitle: l10n.repeater_settingsSubtitle,
color: Theme.of(context).colorScheme.error,
accentColor: MeshPalette.alert,
onTap: () {
HapticFeedback.selectionClick();
Navigator.push(
context,
MaterialPageRoute(
@@ -297,66 +266,76 @@ class RepeaterHubScreen extends StatelessWidget {
);
},
),
],
],
),
),
);
}
}
Widget _buildManagementCard(
BuildContext context, {
required IconData icon,
required String title,
required String subtitle,
required Color color,
required VoidCallback onTap,
}) {
return Card(
elevation: 2,
child: InkWell(
class _HubActionTile extends StatelessWidget {
final int index;
final IconData icon;
final String title;
final String subtitle;
final Color accentColor;
final VoidCallback onTap;
const _HubActionTile({
required this.index,
required this.icon,
required this.title,
required this.subtitle,
required this.accentColor,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ListEntrance(
index: index,
child: MeshCard(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 32),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: accentColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(MeshRadii.md),
border: Border.all(color: accentColor.withValues(alpha: 0.3)),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
alignment: Alignment.center,
child: Icon(icon, size: 22, color: accentColor),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontSize: 12.5,
color: scheme.onSurfaceVariant,
),
],
),
),
],
),
Icon(
Icons.chevron_right,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
Icon(Icons.chevron_right, color: scheme.onSurfaceVariant, size: 20),
],
),
),
);
File diff suppressed because it is too large Load Diff
+226 -236
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
@@ -10,7 +11,9 @@ import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../services/app_settings_service.dart';
import '../services/repeater_command_service.dart';
import '../theme/mesh_theme.dart';
import '../utils/battery_utils.dart';
import '../widgets/mesh_ui.dart';
import '../widgets/routing_sheet.dart';
import '../helpers/snack_bar_builder.dart';
@@ -64,8 +67,6 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
_commandService = RepeaterCommandService(connector);
_setupMessageListener();
// Defer until after the first frame so any notifyListeners() triggered
// during preparePathForContactSend doesn't fire mid-build.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _loadStatus();
});
@@ -81,12 +82,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
void _setupMessageListener() {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Listen for incoming text messages from the repeater
_frameSubscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return;
// Check if it's a text message response
if (frame[0] == pushCodeStatusResponse) {
_handleStatusResponse(frame);
} else if (frame[0] == respCodeContactMsgRecv ||
@@ -118,11 +115,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
final parsed = parseContactMessageText(frame);
if (parsed == null) return;
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
// Notify command service of response (for retry handling)
_commandService?.handleResponse(widget.repeater, parsed.text);
// Parse status responses
_parseStatusResponse(parsed.text);
_recordStatusResult(true);
}
@@ -131,7 +124,6 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
if (frame.length < 8) return;
final prefix = frame.sublist(2, 8);
if (!_matchesRepeaterPrefix(prefix)) return;
if (frame.length < _statusResponseBytes) return;
final data = ByteData.sublistView(
@@ -254,14 +246,9 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
_dupFlood = _asInt(data['dup_flood']);
_dupDirect = _asInt(data['dup_direct']);
}
} catch (_) {
// Ignore parse failures for non-JSON responses.
}
}
if (mounted) {
setState(() {});
} catch (_) {}
}
if (mounted) setState(() {});
}
Future<void> _loadStatus() async {
@@ -302,9 +289,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
var messageBytes = frame.length >= _statusResponseBytes
? frame.length
: _statusResponseBytes;
if (messageBytes < maxFrameSize) {
messageBytes = maxFrameSize;
}
if (messageBytes < maxFrameSize) messageBytes = maxFrameSize;
final timeoutMs = connector.calculateTimeout(
pathLength: pathLengthValue,
messageBytes: messageBytes,
@@ -312,9 +297,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
_statusTimeout?.cancel();
_statusTimeout = Timer(Duration(milliseconds: timeoutMs), () {
if (!mounted) return;
setState(() {
_isLoading = false;
});
setState(() => _isLoading = false);
showDismissibleSnackBar(
context,
content: Text(context.l10n.repeater_statusRequestTimeout),
@@ -324,10 +307,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
});
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
setState(() => _isLoading = false);
showDismissibleSnackBar(
context,
content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())),
@@ -347,214 +327,6 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
_pendingStatusSelection = null;
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
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: [
IconButton(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: l10n.repeater_routingMode,
onPressed: () =>
ContactRoutingSheet.show(context, contact: repeater),
),
IconButton(
icon: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: _isLoading ? null : _loadStatus,
tooltip: l10n.repeater_refresh,
),
],
),
body: SafeArea(
top: false,
child: RefreshIndicator(
onRefresh: _loadStatus,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildSystemInfoCard(),
const SizedBox(height: 16),
_buildRadioStatsCard(),
const SizedBox(height: 16),
_buildPacketStatsCard(),
],
),
),
),
);
}
Widget _buildSystemInfoCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
Text(
l10n.repeater_systemInformation,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
_buildInfoRow(l10n.repeater_battery, _batteryText()),
_buildInfoRow(l10n.repeater_clockAtLogin, _clockText()),
_buildInfoRow(l10n.repeater_uptime, _formatDuration(_uptimeSecs)),
_buildInfoRow(l10n.repeater_queueLength, _formatValue(_queueLen)),
_buildInfoRow(l10n.repeater_debugFlags, _formatValue(_debugFlags)),
],
),
),
);
}
Widget _buildRadioStatsCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.radio,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
Text(
l10n.repeater_radioStatistics,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
_buildInfoRow(
l10n.repeater_lastRssi,
_formatValue(_lastRssi, suffix: ' dB'),
),
_buildInfoRow(l10n.repeater_lastSnr, _formatSnr(_lastSnr)),
_buildInfoRow(
l10n.repeater_noiseFloor,
_formatValue(_noiseFloor, suffix: ' dB'),
),
_buildInfoRow(l10n.repeater_txAirtime, _formatDuration(_txAirSecs)),
_buildInfoRow(l10n.repeater_rxAirtime, _formatDuration(_rxAirSecs)),
],
),
),
);
}
Widget _buildPacketStatsCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.analytics,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
Text(
l10n.repeater_packetStatistics,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
_buildInfoRow(l10n.repeater_sent, _packetTxText()),
_buildInfoRow(l10n.repeater_received, _packetRxText()),
_buildInfoRow(l10n.repeater_duplicates, _duplicateText()),
_buildInfoRow(l10n.repeater_chanUtil, _chanUtilText()),
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
label,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.w400),
textAlign: TextAlign.end,
),
],
),
);
}
int? _asInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
@@ -661,4 +433,222 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
if (snr == null) return '';
return snr.toStringAsFixed(2);
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
return Scaffold(
appBar: AppBar(
title: Text(l10n.repeater_statusTitle),
centerTitle: true,
actions: [
IconButton(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: l10n.repeater_routingMode,
onPressed: () =>
ContactRoutingSheet.show(context, contact: repeater),
),
IconButton(
icon: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: _isLoading ? null : _loadStatus,
tooltip: l10n.repeater_refresh,
),
],
),
body: SafeArea(
top: false,
child: RefreshIndicator(
onRefresh: _loadStatus,
child: _isLoading && _batteryMv == null
? const Center(child: CircularProgressIndicator())
: _buildBody(l10n, repeater.name),
),
),
);
}
Widget _buildBody(dynamic l10n, String name) {
final scheme = Theme.of(context).colorScheme;
return ListView(
padding: const EdgeInsets.only(bottom: 24),
children: [
// System
SectionHeader(l10n.repeater_systemInformation),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _buildStatGrid([
_StatItem(
icon: Icons.battery_std,
label: l10n.repeater_battery,
value: _batteryText(),
color: _batteryColor(),
),
_StatItem(
icon: Icons.timer_outlined,
label: l10n.repeater_uptime,
value: _formatDuration(_uptimeSecs),
color: MeshPalette.blue,
),
_StatItem(
icon: Icons.schedule,
label: l10n.repeater_clockAtLogin,
value: _clockText(),
color: scheme.onSurfaceVariant,
),
_StatItem(
icon: Icons.inbox,
label: l10n.repeater_queueLength,
value: _formatValue(_queueLen),
color: scheme.onSurfaceVariant,
),
_StatItem(
icon: Icons.bug_report_outlined,
label: l10n.repeater_debugFlags,
value: _formatValue(_debugFlags),
color: _debugFlags != null && _debugFlags! > 0
? MeshPalette.warn
: scheme.onSurfaceVariant,
),
]),
),
// Radio
SectionHeader(l10n.repeater_radioStatistics),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _buildStatGrid([
_StatItem(
icon: Icons.signal_cellular_alt,
label: l10n.repeater_lastRssi,
value: _formatValue(_lastRssi, suffix: ' dB'),
color: MeshPalette.blue,
),
_StatItem(
icon: Icons.waves,
label: l10n.repeater_lastSnr,
value: _formatSnr(_lastSnr),
color: MeshTheme.snrColor(_lastSnr, blocked: false),
),
_StatItem(
icon: Icons.noise_control_off,
label: l10n.repeater_noiseFloor,
value: _formatValue(_noiseFloor, suffix: ' dB'),
color: scheme.onSurfaceVariant,
),
_StatItem(
icon: Icons.upload,
label: l10n.repeater_txAirtime,
value: _formatDuration(_txAirSecs),
color: MeshPalette.warn,
),
_StatItem(
icon: Icons.download,
label: l10n.repeater_rxAirtime,
value: _formatDuration(_rxAirSecs),
color: MeshPalette.signal,
),
]),
),
// Packets
SectionHeader(l10n.repeater_packetStatistics),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _buildStatGrid([
_StatItem(
icon: Icons.send,
label: l10n.repeater_sent,
value: _packetTxText(),
color: MeshPalette.blue,
),
_StatItem(
icon: Icons.call_received,
label: l10n.repeater_received,
value: _packetRxText(),
color: MeshPalette.signal,
),
_StatItem(
icon: Icons.content_copy,
label: l10n.repeater_duplicates,
value: _duplicateText(),
color: scheme.onSurfaceVariant,
),
_StatItem(
icon: Icons.percent,
label: l10n.repeater_chanUtil,
value: _chanUtilText(),
color: _chanUtil != null && _chanUtil! > 80
? MeshPalette.alert
: _chanUtil != null && _chanUtil! > 50
? MeshPalette.warn
: MeshPalette.signal,
),
]),
),
const SizedBox(height: 8),
],
);
}
Color _batteryColor() {
final connector = context.watch<MeshCoreConnector>();
final batteryMv =
connector.getRepeaterBatteryMillivolts(widget.repeater.publicKeyHex) ??
_batteryMv;
if (batteryMv == null) {
return Theme.of(context).colorScheme.onSurfaceVariant;
}
final percent = estimateBatteryPercentFromMillivolts(
batteryMv,
_batteryChemistry(),
);
if (percent < 20) return MeshPalette.alert;
if (percent < 40) return MeshPalette.warn;
return MeshPalette.signal;
}
Widget _buildStatGrid(List<_StatItem> items) {
return GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 2.2,
children: items
.map(
(item) => StatTile(
icon: item.icon,
label: item.label,
value: item.value,
color: item.color,
),
)
.toList(),
);
}
}
class _StatItem {
final IconData icon;
final String label;
final String value;
final Color color;
const _StatItem({
required this.icon,
required this.label,
required this.value,
required this.color,
});
}
+146 -82
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/platform_info.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:provider/provider.dart';
@@ -7,10 +8,12 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../services/linux_ble_error_classifier.dart';
import '../theme/mesh_theme.dart';
import '../utils/app_logger.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/device_tile.dart';
import '../widgets/empty_state.dart';
import '../widgets/mesh_ui.dart';
import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart';
import 'tcp_screen.dart';
@@ -136,12 +139,21 @@ class _ScannerScreenState extends State<ScannerScreen> {
builder: (context, connector, child) {
return Column(
children: [
// Bluetooth off warning
if (_bluetoothState == BluetoothAdapterState.off)
_bluetoothOffWarning(context),
// Bluetooth off warning slides in/out with AnimatedSize
AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: _bluetoothState == BluetoothAdapterState.off
? _BluetoothOffBanner(
onEnable: PlatformInfo.isAndroid
? () => FlutterBluePlus.turnOn()
: null,
)
: const SizedBox.shrink(),
),
// Status bar
_buildStatusBar(context, connector),
// Connection status header
_ConnectionStatusHeader(connector: connector),
// Device list
Expanded(child: _buildDeviceList(context, connector)),
@@ -158,14 +170,31 @@ class _ScannerScreenState extends State<ScannerScreen> {
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),
onPressed: isBluetoothOff
? null
: () {
HapticFeedback.lightImpact();
_toggleScan(connector);
},
icon: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
transitionBuilder: (child, anim) =>
ScaleTransition(scale: anim, child: child),
child: isScanning
? SizedBox(
key: const ValueKey('scanning'),
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.onPrimary,
),
)
: const Icon(
Icons.bluetooth_searching,
key: ValueKey('idle'),
),
),
label: Text(
isScanning
? context.l10n.scanner_stop
@@ -178,6 +207,15 @@ class _ScannerScreenState extends State<ScannerScreen> {
}
void _toggleScan(MeshCoreConnector connector) {
if (PlatformInfo.isWeb) {
// flutter_blue_plus has no web backend, so a BLE scan silently no-ops in
// the browser. Tell the user instead of leaving them staring at a button.
showDismissibleSnackBar(
context,
content: Text(context.l10n.scanner_bluetoothWebUnsupported),
);
return;
}
if (connector.state == MeshCoreConnectionState.scanning) {
connector.stopScan();
} else {
@@ -189,51 +227,6 @@ class _ScannerScreenState extends State<ScannerScreen> {
}
}
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
String statusText;
Color statusColor;
final l10n = context.l10n;
switch (connector.state) {
case MeshCoreConnectionState.scanning:
statusText = l10n.scanner_scanning;
statusColor = Colors.blue;
break;
case MeshCoreConnectionState.connecting:
statusText = l10n.scanner_connecting;
statusColor = Colors.orange;
break;
case MeshCoreConnectionState.connected:
statusText = l10n.scanner_connectedTo(connector.deviceDisplayName);
statusColor = Colors.green;
break;
case MeshCoreConnectionState.disconnecting:
statusText = l10n.scanner_disconnecting;
statusColor = Colors.orange;
break;
case MeshCoreConnectionState.disconnected:
statusText = l10n.scanner_notConnected;
statusColor = Colors.grey;
break;
}
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: statusColor.withValues(alpha: 0.1),
child: Row(
children: [
Icon(Icons.circle, size: 12, color: statusColor),
const SizedBox(width: 8),
Text(
statusText,
style: TextStyle(color: statusColor, fontWeight: FontWeight.w500),
),
],
),
);
}
Widget _buildDeviceList(BuildContext context, MeshCoreConnector connector) {
if (connector.scanResults.isEmpty) {
final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
@@ -251,7 +244,10 @@ class _ScannerScreenState extends State<ScannerScreen> {
action: (isBluetoothOff || isScanning)
? null
: FilledButton.icon(
onPressed: () => _toggleScan(connector),
onPressed: () {
HapticFeedback.lightImpact();
_toggleScan(connector);
},
icon: const Icon(Icons.bluetooth_searching),
label: Text(context.l10n.scanner_scan),
),
@@ -259,19 +255,21 @@ class _ScannerScreenState extends State<ScannerScreen> {
}
final isConnecting = connector.state == MeshCoreConnectionState.connecting;
return ListView.separated(
padding: const EdgeInsets.all(8),
return ListView.builder(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 96),
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,
isConnecting: isConnecting && _connectingDeviceId == deviceId,
onTap: isConnecting
? null
: () => _connectToDevice(context, connector, result),
return ListEntrance(
index: index,
child: DeviceTile(
scanResult: result,
isConnecting: isConnecting && _connectingDeviceId == deviceId,
onTap: isConnecting
? null
: () => _connectToDevice(context, connector, result),
),
);
},
);
@@ -413,47 +411,113 @@ class _ScannerScreenState extends State<ScannerScreen> {
);
return pin;
}
}
Widget _bluetoothOffWarning(BuildContext context) {
final errorColor = Theme.of(context).colorScheme.error;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
color: errorColor.withValues(alpha: 0.15),
// Private sub-widgets
/// Bluetooth-off warning banner styled as an alert MeshCard.
class _BluetoothOffBanner extends StatelessWidget {
final VoidCallback? onEnable;
const _BluetoothOffBanner({this.onEnable});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return MeshCard(
color: scheme.error.withValues(alpha: 0.08),
borderColor: scheme.error.withValues(alpha: 0.35),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
Icon(Icons.bluetooth_disabled, size: 24, color: errorColor),
const SizedBox(width: 12),
Icon(Icons.bluetooth_disabled, size: 20, color: scheme.error),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.scanner_bluetoothOff,
style: TextStyle(
color: errorColor,
color: scheme.error,
fontWeight: FontWeight.w600,
fontSize: 14,
fontSize: 13.5,
),
),
const SizedBox(height: 4),
const SizedBox(height: 2),
Text(
context.l10n.scanner_bluetoothOffMessage,
style: TextStyle(
color: errorColor.withValues(alpha: 0.85),
color: scheme.error.withValues(alpha: 0.8),
fontSize: 12,
),
),
],
),
),
if (PlatformInfo.isAndroid)
if (onEnable != null) ...[
const SizedBox(width: 8),
TextButton(
onPressed: () => FlutterBluePlus.turnOn(),
onPressed: onEnable,
child: Text(context.l10n.scanner_enableBluetooth),
),
],
],
),
);
}
}
/// Connection status header with AnimatedSwitcher between states.
class _ConnectionStatusHeader extends StatelessWidget {
final MeshCoreConnector connector;
const _ConnectionStatusHeader({required this.connector});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final (String label, Color color, bool pulse) = switch (connector.state) {
MeshCoreConnectionState.scanning => (
l10n.scanner_scanning,
MeshPalette.blue,
true,
),
MeshCoreConnectionState.connecting => (
l10n.scanner_connecting,
MeshPalette.warn,
true,
),
MeshCoreConnectionState.connected => (
l10n.scanner_connectedTo(connector.deviceDisplayName),
MeshPalette.signal,
false,
),
MeshCoreConnectionState.disconnecting => (
l10n.scanner_disconnecting,
MeshPalette.warn,
true,
),
MeshCoreConnectionState.disconnected => (
l10n.scanner_notConnected,
scheme.onSurfaceVariant,
false,
),
};
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Align(
key: ValueKey(connector.state),
alignment: Alignment.centerLeft,
child: StatusChip(label: label, color: color, pulse: pulse),
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+98 -56
View File
@@ -1,13 +1,16 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../services/app_settings_service.dart';
import '../theme/mesh_theme.dart';
import '../utils/platform_info.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/mesh_ui.dart';
import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart';
import 'usb_screen.dart';
@@ -95,15 +98,32 @@ class _TcpScreenState extends State<TcpScreen> {
final isConnecting =
connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.tcp;
// 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(
// Connect is only available from a fully disconnected state
// scanning, connecting, or an active session must settle first.
final isButtonDisabled =
connector.state != MeshCoreConnectionState.disconnected;
return ListView(
padding: const EdgeInsets.only(bottom: 32),
children: [
_buildStatusBar(context, connector),
_buildTransportLinks(context),
// Status header
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Align(
key: ValueKey(connector.state),
alignment: Alignment.centerLeft,
child: _buildStatusChip(context, connector),
),
),
),
// Transport switcher
_buildTransportLinks(context),
// Connection form
const SectionHeader('TCP / IP'),
MeshCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -113,7 +133,6 @@ class _TcpScreenState extends State<TcpScreen> {
decoration: InputDecoration(
labelText: context.l10n.tcpHostLabel,
hintText: context.l10n.tcpHostHint,
border: const OutlineInputBorder(),
),
enabled: !isConnecting,
keyboardType: TextInputType.url,
@@ -124,7 +143,6 @@ class _TcpScreenState extends State<TcpScreen> {
decoration: InputDecoration(
labelText: context.l10n.tcpPortLabel,
hintText: context.l10n.tcpPortHint,
border: const OutlineInputBorder(),
),
enabled: !isConnecting,
keyboardType: TextInputType.number,
@@ -132,7 +150,12 @@ class _TcpScreenState extends State<TcpScreen> {
const SizedBox(height: 16),
FilledButton.icon(
key: const Key('tcp_connect_button'),
onPressed: isButtonDisabled ? null : _connectTcp,
onPressed: isButtonDisabled
? null
: () {
HapticFeedback.lightImpact();
_connectTcp();
},
icon: isConnecting
? const SizedBox(
width: 18,
@@ -151,6 +174,39 @@ class _TcpScreenState extends State<TcpScreen> {
],
),
),
// Last used endpoint
if (connector.activeTcpEndpoint != null &&
connector.isTcpTransportConnected) ...[
const SectionHeader('CONNECTED TO'),
MeshCard(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
child: Row(
children: [
Icon(
Icons.lan,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 10),
Expanded(
child: Text(
connector.activeTcpEndpoint!,
style: MeshTheme.mono(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
],
);
},
@@ -159,6 +215,38 @@ class _TcpScreenState extends State<TcpScreen> {
);
}
Widget _buildStatusChip(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
if (connector.isTcpTransportConnected) {
return StatusChip(
label: l10n.scanner_connectedTo(connector.activeTcpEndpoint ?? 'TCP'),
color: MeshPalette.signal,
);
} else if (connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.tcp) {
return StatusChip(
label: l10n.tcpStatus_connectingTo(
'${_hostController.text}:${_portController.text}',
),
color: MeshPalette.warn,
pulse: true,
);
} else if (connector.state == MeshCoreConnectionState.disconnecting &&
connector.activeTransport == MeshCoreTransportType.tcp) {
return StatusChip(
label: l10n.scanner_disconnecting,
color: MeshPalette.warn,
pulse: true,
);
} else {
return StatusChip(
label: l10n.tcpStatus_notConnected,
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
}
}
Widget _buildTransportLinks(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -186,52 +274,6 @@ class _TcpScreenState extends State<TcpScreen> {
);
}
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
String statusText;
Color statusColor;
if (connector.isTcpTransportConnected) {
statusText = l10n.scanner_connectedTo(
connector.activeTcpEndpoint ?? 'TCP',
);
statusColor = Colors.green;
} else if (connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.tcp) {
statusText = l10n.tcpStatus_connectingTo(
'${_hostController.text}:${_portController.text}',
);
statusColor = Colors.orange;
} else if (connector.state == MeshCoreConnectionState.disconnecting &&
connector.activeTransport == MeshCoreTransportType.tcp) {
statusText = l10n.scanner_disconnecting;
statusColor = Colors.orange;
} else {
statusText = l10n.tcpStatus_notConnected;
statusColor = Theme.of(context).colorScheme.onSurfaceVariant;
}
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: statusColor.withValues(alpha: 0.1),
child: Row(
children: [
Icon(Icons.circle, size: 12, color: statusColor),
const SizedBox(width: 8),
Expanded(
child: Text(
statusText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: statusColor, fontWeight: FontWeight.w500),
),
),
],
),
);
}
Future<void> _connectTcp() async {
if (_connector.state == MeshCoreConnectionState.connecting ||
_connector.state == MeshCoreConnectionState.connected ||
+89 -105
View File
@@ -19,6 +19,8 @@ import '../utils/battery_utils.dart';
import '../helpers/snack_bar_builder.dart';
import '../widgets/sync_progress_overlay.dart';
import '../widgets/telemetry_location_map.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
class TelemetryScreen extends StatefulWidget {
final Contact contact;
@@ -319,6 +321,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final connector = context.watch<MeshCoreConnector>();
final settings = context.watch<AppSettingsService>().settings;
final isImperialUnits = settings.unitSystem == UnitSystem.imperial;
@@ -387,7 +390,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
l10n.telemetry_noData,
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
color: scheme.onSurfaceVariant,
),
),
),
@@ -415,34 +418,21 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
int channel,
bool isImperialUnits,
) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
for (final entry in channelData.entries)
_buildTelemetryField(entry, channel, isImperialUnits),
],
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionHeader(title, padding: const EdgeInsets.fromLTRB(16, 16, 16, 8)),
MeshCard(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final entry in channelData.entries)
_buildTelemetryField(entry, channel, isImperialUnits),
],
),
),
),
],
);
}
@@ -601,89 +591,81 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
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),
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionHeader(
l10n.common_autoRefresh,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
),
MeshCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_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(
l10n.common_autoRefresh,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
counterText,
textAlign: TextAlign.center,
style: TextStyle(
color: _autoRefreshLastAttemptFailed
? Theme.of(context).colorScheme.error
: null,
fontWeight: FontWeight.w600,
),
),
],
),
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,
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),
),
],
],
),
)
: Text(l10n.common_enable),
),
],
),
),
),
],
);
}
@@ -913,6 +895,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
}
Widget _buildInfoRow(String label, String value) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
@@ -922,7 +905,8 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
child: Text(
label,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
color: scheme.onSurfaceVariant,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
@@ -930,7 +914,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
const SizedBox(width: 8),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.w400),
style: MeshTheme.mono(fontSize: 13, color: scheme.onSurface),
textAlign: TextAlign.end,
),
],
+127 -105
View File
@@ -6,10 +6,13 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../theme/mesh_theme.dart';
import '../utils/app_logger.dart';
import '../utils/platform_info.dart';
import '../utils/usb_port_labels.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/empty_state.dart';
import '../widgets/mesh_ui.dart';
import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart';
import 'tcp_screen.dart';
@@ -97,9 +100,25 @@ class _UsbScreenState extends State<UsbScreen> {
child: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildStatusBar(context, connector),
// Status header
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Align(
key: ValueKey('${connector.state}_$_isLoadingPorts'),
alignment: Alignment.centerLeft,
child: _buildStatusChip(context, connector),
),
),
),
// Transport switcher
_buildTransportLinks(context),
// Port list
Expanded(child: _buildPortList(context, connector)),
],
);
@@ -132,6 +151,52 @@ class _UsbScreenState extends State<UsbScreen> {
);
}
Widget _buildStatusChip(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
if (_isLoadingPorts) {
return StatusChip(
label: l10n.usbStatus_searching,
color: scheme.primary,
pulse: true,
);
} else if (connector.isUsbTransportConnected) {
switch (connector.state) {
case MeshCoreConnectionState.connected:
return StatusChip(
label: l10n.scanner_connectedTo(
connector.activeUsbPortDisplayLabel ?? 'USB',
),
color: MeshPalette.signal,
);
case MeshCoreConnectionState.disconnecting:
return StatusChip(
label: l10n.scanner_disconnecting,
color: MeshPalette.warn,
pulse: true,
);
default:
return StatusChip(
label: l10n.usbStatus_notConnected,
color: scheme.onSurfaceVariant,
);
}
} else if (connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.usb) {
return StatusChip(
label: l10n.usbStatus_connecting,
color: MeshPalette.warn,
pulse: true,
);
} else {
return StatusChip(
label: l10n.usbStatus_notConnected,
color: scheme.onSurfaceVariant,
);
}
}
Widget _buildTransportLinks(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -159,116 +224,24 @@ class _UsbScreenState extends State<UsbScreen> {
);
}
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
String statusText;
Color statusColor;
if (_isLoadingPorts) {
statusText = l10n.usbStatus_searching;
statusColor = Theme.of(context).colorScheme.primary;
} else if (connector.isUsbTransportConnected) {
switch (connector.state) {
case MeshCoreConnectionState.connected:
statusText = l10n.scanner_connectedTo(
connector.activeUsbPortDisplayLabel ?? 'USB',
);
statusColor = Colors.green;
case MeshCoreConnectionState.disconnecting:
statusText = l10n.scanner_disconnecting;
statusColor = Colors.orange;
default:
statusText = l10n.usbStatus_notConnected;
statusColor = Theme.of(context).colorScheme.onSurfaceVariant;
}
} else if (connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.usb) {
statusText = l10n.usbStatus_connecting;
statusColor = Colors.orange;
} else {
statusText = l10n.usbStatus_notConnected;
statusColor = Theme.of(context).colorScheme.onSurfaceVariant;
}
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: statusColor.withValues(alpha: 0.1),
child: Row(
children: [
Icon(Icons.circle, size: 12, color: statusColor),
const SizedBox(width: 8),
Expanded(
child: Text(
statusText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: statusColor, fontWeight: FontWeight.w500),
),
),
],
),
);
}
Widget _buildPortList(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
if (_isLoadingPorts) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.usb,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
l10n.usbStatus_searching,
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
return EmptyState(icon: Icons.usb, title: l10n.usbStatus_searching);
}
if (_ports.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
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: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
return EmptyState(icon: Icons.usb, title: l10n.usbScreenEmptyState);
}
final isConnecting =
connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.usb;
return ListView.separated(
padding: const EdgeInsets.all(8),
return ListView.builder(
padding: const EdgeInsets.only(bottom: 32),
itemCount: _ports.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
final port = _ports[index];
final displayName = friendlyUsbPortName(port);
@@ -276,15 +249,50 @@ class _UsbScreenState extends State<UsbScreen> {
final showRawName =
rawName != displayName && !rawName.startsWith('web:');
return ListTile(
leading: const Icon(Icons.usb),
title: Text(
displayName,
style: const TextStyle(fontWeight: FontWeight.w500),
return ListEntrance(
index: index,
child: MeshCard(
padding: EdgeInsets.zero,
child: ListTile(
onTap: isConnecting
? null
: () {
HapticFeedback.selectionClick();
_connectPort(port);
},
leading: AvatarCircle(
name: displayName,
size: 40,
icon: Icons.usb,
color: Theme.of(context).colorScheme.primary,
),
title: Text(
displayName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: showRawName
? Text(
rawName,
style: MeshTheme.mono(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
trailing: Icon(
Icons.chevron_right,
size: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
subtitle: showRawName ? Text(rawName) : null,
trailing: const Icon(Icons.chevron_right),
onTap: isConnecting ? null : () => _connectPort(port),
);
},
);
@@ -370,6 +378,10 @@ class _UsbScreenState extends State<UsbScreen> {
void _showError(Object error) {
if (!mounted) return;
// Cancelling the browser's serial port picker is a normal user action, not
// an error don't show a scary red toast (and never leak the raw
// DOMException text).
if (_isUserCancelledPortPicker(error)) return;
showDismissibleSnackBar(
context,
content: Text(_friendlyErrorMessage(error)),
@@ -377,6 +389,16 @@ class _UsbScreenState extends State<UsbScreen> {
);
}
bool _isUserCancelledPortPicker(Object error) {
if (error is StateError &&
error.message.contains('No USB serial device selected')) {
return true;
}
final text = error.toString();
return text.contains('No port selected by the user') ||
text.contains("Failed to execute 'requestPort'");
}
String _friendlyErrorMessage(Object error) {
final l10n = context.l10n;