mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-13 12:22:01 +10:00
Merge branch 'dev' into update-pacox-multibyte
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1388
-1012
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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
+562
-452
File diff suppressed because it is too large
Load Diff
+267
-180
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+907
-213
File diff suppressed because it is too large
Load Diff
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+568
-420
File diff suppressed because it is too large
Load Diff
+98
-56
@@ -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 ||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user