mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-10 02:32:47 +10:00
format dart files
formats all dart files using `dart format .` from the root project dir this makes the code style repeatable by new contributors and makes PR review easier
This commit is contained in:
@@ -26,8 +26,10 @@ class AppDebugLogScreen extends StatelessWidget {
|
||||
onPressed: hasEntries
|
||||
? () async {
|
||||
final text = entries
|
||||
.map((entry) =>
|
||||
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}')
|
||||
.map(
|
||||
(entry) =>
|
||||
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}',
|
||||
)
|
||||
.join('\n');
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
if (!context.mounted) return;
|
||||
@@ -61,11 +63,17 @@ class AppDebugLogScreen extends StatelessWidget {
|
||||
leading: _buildLevelIcon(entry.level),
|
||||
title: Text(
|
||||
'[${entry.tag}] ${entry.message}',
|
||||
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
entry.formattedTime,
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -74,16 +82,26 @@ class AppDebugLogScreen extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.bug_report_outlined, size: 64, color: Colors.grey[400]),
|
||||
Icon(
|
||||
Icons.bug_report_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.debugLog_noEntries,
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.debugLog_enableInSettings,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -99,7 +117,11 @@ class AppDebugLogScreen extends StatelessWidget {
|
||||
case AppDebugLogLevel.info:
|
||||
return const Icon(Icons.info_outline, size: 18, color: Colors.blue);
|
||||
case AppDebugLogLevel.warning:
|
||||
return const Icon(Icons.warning_amber_outlined, size: 18, color: Colors.orange);
|
||||
return const Icon(
|
||||
Icons.warning_amber_outlined,
|
||||
size: 18,
|
||||
color: Colors.orange,
|
||||
);
|
||||
case AppDebugLogLevel.error:
|
||||
return const Icon(Icons.error_outline, size: 18, color: Colors.red);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,10 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppearanceCard(BuildContext context, AppSettingsService settingsService) {
|
||||
Widget _buildAppearanceCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -58,7 +61,9 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.brightness_6_outlined),
|
||||
title: Text(context.l10n.appSettings_theme),
|
||||
subtitle: Text(_themeModeLabel(context, settingsService.settings.themeMode)),
|
||||
subtitle: Text(
|
||||
_themeModeLabel(context, settingsService.settings.themeMode),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showThemeModeDialog(context, settingsService),
|
||||
),
|
||||
@@ -66,7 +71,12 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.language_outlined),
|
||||
title: Text(context.l10n.appSettings_language),
|
||||
subtitle: Text(_languageLabel(context, settingsService.settings.languageOverride)),
|
||||
subtitle: Text(
|
||||
_languageLabel(
|
||||
context,
|
||||
settingsService.settings.languageOverride,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showLanguageDialog(context, settingsService),
|
||||
),
|
||||
@@ -75,7 +85,10 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotificationsCard(BuildContext context, AppSettingsService settingsService) {
|
||||
Widget _buildNotificationsCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -90,17 +103,22 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.notifications_outlined),
|
||||
title: Text(context.l10n.appSettings_enableNotifications),
|
||||
subtitle: Text(context.l10n.appSettings_enableNotificationsSubtitle),
|
||||
subtitle: Text(
|
||||
context.l10n.appSettings_enableNotificationsSubtitle,
|
||||
),
|
||||
value: settingsService.settings.notificationsEnabled,
|
||||
onChanged: (value) async {
|
||||
if (value) {
|
||||
// Request permission when enabling
|
||||
final granted = await NotificationService().requestPermissions();
|
||||
final granted = await NotificationService()
|
||||
.requestPermissions();
|
||||
if (!granted) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.appSettings_notificationPermissionDenied),
|
||||
content: Text(
|
||||
context.l10n.appSettings_notificationPermissionDenied,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -113,9 +131,11 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(value
|
||||
? context.l10n.appSettings_notificationsEnabled
|
||||
: context.l10n.appSettings_notificationsDisabled),
|
||||
content: Text(
|
||||
value
|
||||
? context.l10n.appSettings_notificationsEnabled
|
||||
: context.l10n.appSettings_notificationsDisabled,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -126,18 +146,24 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
SwitchListTile(
|
||||
secondary: Icon(
|
||||
Icons.message_outlined,
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
context.l10n.appSettings_messageNotifications,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
context.l10n.appSettings_messageNotificationsSubtitle,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
value: settingsService.settings.notifyOnNewMessage,
|
||||
@@ -151,18 +177,24 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
SwitchListTile(
|
||||
secondary: Icon(
|
||||
Icons.forum_outlined,
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
context.l10n.appSettings_channelMessageNotifications,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
context.l10n.appSettings_channelMessageNotificationsSubtitle,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
value: settingsService.settings.notifyOnNewChannelMessage,
|
||||
@@ -176,18 +208,24 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
SwitchListTile(
|
||||
secondary: Icon(
|
||||
Icons.cell_tower,
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
context.l10n.appSettings_advertisementNotifications,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
context.l10n.appSettings_advertisementNotificationsSubtitle,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
value: settingsService.settings.notifyOnNewAdvert,
|
||||
@@ -202,7 +240,10 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessagingCard(BuildContext context, AppSettingsService settingsService) {
|
||||
Widget _buildMessagingCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -217,15 +258,19 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.refresh_outlined),
|
||||
title: Text(context.l10n.appSettings_clearPathOnMaxRetry),
|
||||
subtitle: Text(context.l10n.appSettings_clearPathOnMaxRetrySubtitle),
|
||||
subtitle: Text(
|
||||
context.l10n.appSettings_clearPathOnMaxRetrySubtitle,
|
||||
),
|
||||
value: settingsService.settings.clearPathOnMaxRetry,
|
||||
onChanged: (value) {
|
||||
settingsService.setClearPathOnMaxRetry(value);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(value
|
||||
? context.l10n.appSettings_pathsWillBeCleared
|
||||
: context.l10n.appSettings_pathsWillNotBeCleared),
|
||||
content: Text(
|
||||
value
|
||||
? context.l10n.appSettings_pathsWillBeCleared
|
||||
: context.l10n.appSettings_pathsWillNotBeCleared,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -241,9 +286,11 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
settingsService.setAutoRouteRotationEnabled(value);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(value
|
||||
? context.l10n.appSettings_autoRouteRotationEnabled
|
||||
: context.l10n.appSettings_autoRouteRotationDisabled),
|
||||
content: Text(
|
||||
value
|
||||
? context.l10n.appSettings_autoRouteRotationEnabled
|
||||
: context.l10n.appSettings_autoRouteRotationDisabled,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -254,7 +301,10 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMapSettingsCard(BuildContext context, AppSettingsService settingsService) {
|
||||
Widget _buildMapSettingsCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -302,7 +352,9 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
subtitle: Text(
|
||||
settingsService.settings.mapTimeFilterHours == 0
|
||||
? context.l10n.appSettings_timeFilterShowAll
|
||||
: context.l10n.appSettings_timeFilterShowLast(settingsService.settings.mapTimeFilterHours.toInt()),
|
||||
: context.l10n.appSettings_timeFilterShowLast(
|
||||
settingsService.settings.mapTimeFilterHours.toInt(),
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showTimeFilterDialog(context, settingsService),
|
||||
@@ -339,8 +391,9 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
) {
|
||||
final deviceId = connector.deviceId;
|
||||
final isConnected = connector.isConnected && deviceId != null;
|
||||
final selection =
|
||||
isConnected ? settingsService.batteryChemistryForDevice(deviceId) : 'nmc';
|
||||
final selection = isConnected
|
||||
? settingsService.batteryChemistryForDevice(deviceId)
|
||||
: 'nmc';
|
||||
|
||||
return Card(
|
||||
child: Column(
|
||||
@@ -358,7 +411,9 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
title: Text(context.l10n.appSettings_batteryChemistry),
|
||||
subtitle: Text(
|
||||
isConnected
|
||||
? context.l10n.appSettings_batteryChemistryPerDevice(connector.deviceDisplayName)
|
||||
? context.l10n.appSettings_batteryChemistryPerDevice(
|
||||
connector.deviceDisplayName,
|
||||
)
|
||||
: context.l10n.appSettings_batteryChemistryConnectFirst,
|
||||
),
|
||||
trailing: DropdownButton<String>(
|
||||
@@ -366,7 +421,10 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
onChanged: isConnected
|
||||
? (value) {
|
||||
if (value != null) {
|
||||
settingsService.setBatteryChemistryForDevice(deviceId, value);
|
||||
settingsService.setBatteryChemistryForDevice(
|
||||
deviceId,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
@@ -391,7 +449,10 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showThemeModeDialog(BuildContext context, AppSettingsService settingsService) {
|
||||
void _showThemeModeDialog(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
@@ -480,7 +541,10 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void _showLanguageDialog(BuildContext context, AppSettingsService settingsService) {
|
||||
void _showLanguageDialog(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
@@ -573,7 +637,10 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showTimeFilterDialog(BuildContext context, AppSettingsService settingsService) {
|
||||
void _showTimeFilterDialog(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
@@ -593,33 +660,23 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_allTime),
|
||||
leading: Radio<double>(
|
||||
value: 0,
|
||||
),
|
||||
leading: Radio<double>(value: 0),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_lastHour),
|
||||
leading: Radio<double>(
|
||||
value: 1,
|
||||
),
|
||||
leading: Radio<double>(value: 1),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_last6Hours),
|
||||
leading: Radio<double>(
|
||||
value: 6,
|
||||
),
|
||||
leading: Radio<double>(value: 6),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_last24Hours),
|
||||
leading: Radio<double>(
|
||||
value: 24,
|
||||
),
|
||||
leading: Radio<double>(value: 24),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_lastWeek),
|
||||
leading: Radio<double>(
|
||||
value: 168,
|
||||
),
|
||||
leading: Radio<double>(value: 168),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -634,7 +691,10 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDebugCard(BuildContext context, AppSettingsService settingsService) {
|
||||
Widget _buildDebugCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -656,9 +716,11 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(value
|
||||
? context.l10n.appSettings_appDebugLoggingEnabled
|
||||
: context.l10n.appSettings_appDebugLoggingDisabled),
|
||||
content: Text(
|
||||
value
|
||||
? context.l10n.appSettings_appDebugLoggingEnabled
|
||||
: context.l10n.appSettings_appDebugLoggingDisabled,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -24,7 +24,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
final entries = logService.entries.reversed.toList();
|
||||
final rawEntries = logService.rawLogRxEntries.reversed.toList();
|
||||
final showingFrames = _view == _BleLogView.frames;
|
||||
final hasEntries = showingFrames ? entries.isNotEmpty : rawEntries.isNotEmpty;
|
||||
final hasEntries = showingFrames
|
||||
? entries.isNotEmpty
|
||||
: rawEntries.isNotEmpty;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.debugLog_bleTitle),
|
||||
@@ -36,15 +38,23 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
? () async {
|
||||
final text = showingFrames
|
||||
? entries
|
||||
.map((entry) => '${entry.description}\n${entry.hexPreview}\n')
|
||||
.join('\n')
|
||||
.map(
|
||||
(entry) =>
|
||||
'${entry.description}\n${entry.hexPreview}\n',
|
||||
)
|
||||
.join('\n')
|
||||
: rawEntries
|
||||
.map((entry) => 'RX RAW_LOG_RX_DATA\n${entry.hexPreview}\n')
|
||||
.join('\n');
|
||||
.map(
|
||||
(entry) =>
|
||||
'RX RAW_LOG_RX_DATA\n${entry.hexPreview}\n',
|
||||
)
|
||||
.join('\n');
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.debugLog_bleCopied)),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.debugLog_bleCopied),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
@@ -68,8 +78,14 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: SegmentedButton<_BleLogView>(
|
||||
segments: [
|
||||
ButtonSegment(value: _BleLogView.frames, label: Text(context.l10n.debugLog_frames)),
|
||||
ButtonSegment(value: _BleLogView.rawLogRx, label: Text(context.l10n.debugLog_rawLogRx)),
|
||||
ButtonSegment(
|
||||
value: _BleLogView.frames,
|
||||
label: Text(context.l10n.debugLog_frames),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _BleLogView.rawLogRx,
|
||||
label: Text(context.l10n.debugLog_rawLogRx),
|
||||
),
|
||||
],
|
||||
selected: {_view},
|
||||
onSelectionChanged: (selection) {
|
||||
@@ -81,7 +97,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
Expanded(
|
||||
child: hasEntries
|
||||
? ListView.separated(
|
||||
itemCount: showingFrames ? entries.length : rawEntries.length,
|
||||
itemCount: showingFrames
|
||||
? entries.length
|
||||
: rawEntries.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
if (showingFrames) {
|
||||
@@ -94,7 +112,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
subtitle: Text('${entry.hexPreview}\n$time'),
|
||||
isThreeLine: true,
|
||||
leading: Icon(
|
||||
entry.outgoing ? Icons.upload : Icons.download,
|
||||
entry.outgoing
|
||||
? Icons.upload
|
||||
: Icons.download,
|
||||
size: 18,
|
||||
),
|
||||
);
|
||||
@@ -131,9 +151,7 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(info.title),
|
||||
content: SingleChildScrollView(
|
||||
child: SelectableText(info.rawHex),
|
||||
),
|
||||
content: SingleChildScrollView(child: SelectableText(info.rawHex)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
@@ -195,11 +213,18 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
}
|
||||
final payload = raw.sublist(index);
|
||||
|
||||
final title = 'RX ${_payloadTypeLabel(payloadType)} • ${_routeLabel(routeType)} • v$payloadVer';
|
||||
final title =
|
||||
'RX ${_payloadTypeLabel(payloadType)} • ${_routeLabel(routeType)} • v$payloadVer';
|
||||
final summary = _decodePayloadSummary(payloadType, payload);
|
||||
final pathSummary = pathLen > 0 ? 'Path=${_bytesToHex(pathBytes)}' : 'Path=none';
|
||||
final pathSummary = pathLen > 0
|
||||
? 'Path=${_bytesToHex(pathBytes)}'
|
||||
: 'Path=none';
|
||||
final detail = '$summary • $pathSummary • len=${raw.length}';
|
||||
return _RawPacketInfo(title: title, summary: detail, rawHex: _bytesToHex(raw));
|
||||
return _RawPacketInfo(
|
||||
title: title,
|
||||
summary: detail,
|
||||
rawHex: _bytesToHex(raw),
|
||||
);
|
||||
}
|
||||
|
||||
String _decodePayloadSummary(int payloadType, Uint8List payload) {
|
||||
@@ -245,7 +270,10 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
return 'ADVERT (short)';
|
||||
}
|
||||
var offset = 0;
|
||||
final pubKey = _bytesToHex(payload.sublist(offset, offset + 32), spaced: false);
|
||||
final pubKey = _bytesToHex(
|
||||
payload.sublist(offset, offset + 32),
|
||||
spaced: false,
|
||||
);
|
||||
offset += 32;
|
||||
final timestamp = readUint32LE(payload, offset);
|
||||
offset += 4;
|
||||
|
||||
@@ -27,10 +27,7 @@ import 'map_screen.dart';
|
||||
class ChannelChatScreen extends StatefulWidget {
|
||||
final Channel channel;
|
||||
|
||||
const ChannelChatScreen({
|
||||
super.key,
|
||||
required this.channel,
|
||||
});
|
||||
const ChannelChatScreen({super.key, required this.channel});
|
||||
|
||||
@override
|
||||
State<ChannelChatScreen> createState() => _ChannelChatScreenState();
|
||||
@@ -135,15 +132,19 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
children: [
|
||||
Text(
|
||||
widget.channel.name.isEmpty
|
||||
? context.l10n.channels_channelIndex(widget.channel.index)
|
||||
? context.l10n.channels_channelIndex(
|
||||
widget.channel.index,
|
||||
)
|
||||
: widget.channel.name,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final unreadCount =
|
||||
connector.getUnreadCountForChannelIndex(widget.channel.index);
|
||||
final privacy = widget.channel.isPublicChannel ? context.l10n.channels_public : context.l10n.channels_private;
|
||||
final unreadCount = connector
|
||||
.getUnreadCountForChannelIndex(widget.channel.index);
|
||||
final privacy = widget.channel.isPublicChannel
|
||||
? context.l10n.channels_public
|
||||
: context.l10n.channels_private;
|
||||
return Text(
|
||||
'$privacy • ${context.l10n.chat_unread(unreadCount)}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -202,7 +203,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
|
||||
// Reverse messages so newest appear at bottom with reverse: true
|
||||
final reversedMessages = messages.reversed.toList();
|
||||
final itemCount = reversedMessages.length + (_isLoadingOlder ? 1 : 0);
|
||||
final itemCount =
|
||||
reversedMessages.length + (_isLoadingOlder ? 1 : 0);
|
||||
|
||||
// Auto-scroll to bottom if user is already at bottom
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -225,7 +227,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -241,9 +245,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
);
|
||||
},
|
||||
),
|
||||
JumpToBottomButton(
|
||||
scrollController: _scrollController,
|
||||
),
|
||||
JumpToBottomButton(scrollController: _scrollController),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -262,15 +264,21 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
final poi = _parsePoiMessage(message.text);
|
||||
final displayPath = message.pathBytes.isNotEmpty
|
||||
? message.pathBytes
|
||||
: (message.pathVariants.isNotEmpty ? message.pathVariants.first : Uint8List(0));
|
||||
: (message.pathVariants.isNotEmpty
|
||||
? message.pathVariants.first
|
||||
: Uint8List(0));
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
crossAxisAlignment: isOutgoing
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
mainAxisAlignment: isOutgoing
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOutgoing) ...[
|
||||
@@ -282,128 +290,160 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
onTap: () => _showMessagePathInfo(message),
|
||||
onLongPress: () => _showMessageActions(message),
|
||||
child: Container(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.all(4)
|
||||
: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.65,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isOutgoing
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOutgoing) ...[
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.only(left: 8, top: 4, bottom: 4)
|
||||
: EdgeInsets.zero,
|
||||
child: Text(
|
||||
message.senderName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.all(4)
|
||||
: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (gifId == null) const SizedBox(height: 4),
|
||||
],
|
||||
if (message.replyToMessageId != null) ...[
|
||||
_buildReplyPreview(message),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (poi != null)
|
||||
_buildPoiMessage(context, poi, isOutgoing)
|
||||
else if (gifId != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: GifMessage(
|
||||
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: Colors.transparent,
|
||||
fallbackTextColor: isOutgoing
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.7)
|
||||
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
)
|
||||
else
|
||||
Linkify(
|
||||
text: message.text,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
linkStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.green,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
options: const LinkifyOptions(
|
||||
humanize: false,
|
||||
defaultToHttps: false,
|
||||
),
|
||||
linkifiers: const [UrlLinkifier()],
|
||||
onOpen: (link) => LinkHandler.handleLinkTap(context, link.url),
|
||||
),
|
||||
if (displayPath.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.symmetric(horizontal: 8)
|
||||
: EdgeInsets.zero,
|
||||
child: Text(
|
||||
'via ${_formatPathPrefixes(displayPath)}',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.only(left: 8, right: 8, bottom: 4)
|
||||
: EdgeInsets.zero,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(message.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.65,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isOutgoing
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOutgoing) ...[
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.only(
|
||||
left: 8,
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
)
|
||||
: EdgeInsets.zero,
|
||||
child: Text(
|
||||
message.senderName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (message.repeatCount > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(Icons.repeat, size: 12, color: Colors.grey[600]),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'${message.repeatCount}',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||
if (gifId == null) const SizedBox(height: 4),
|
||||
],
|
||||
if (message.replyToMessageId != null) ...[
|
||||
_buildReplyPreview(message),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (poi != null)
|
||||
_buildPoiMessage(context, poi, isOutgoing)
|
||||
else if (gifId != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: GifMessage(
|
||||
url:
|
||||
'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: Colors.transparent,
|
||||
fallbackTextColor: isOutgoing
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer
|
||||
.withValues(alpha: 0.7)
|
||||
: Theme.of(context).colorScheme.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
if (isOutgoing) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
message.status == ChannelMessageStatus.sent
|
||||
? Icons.check
|
||||
: message.status == ChannelMessageStatus.pending
|
||||
)
|
||||
else
|
||||
Linkify(
|
||||
text: message.text,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
linkStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.green,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
options: const LinkifyOptions(
|
||||
humanize: false,
|
||||
defaultToHttps: false,
|
||||
),
|
||||
linkifiers: const [UrlLinkifier()],
|
||||
onOpen: (link) =>
|
||||
LinkHandler.handleLinkTap(context, link.url),
|
||||
),
|
||||
if (displayPath.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.symmetric(horizontal: 8)
|
||||
: EdgeInsets.zero,
|
||||
child: Text(
|
||||
'via ${_formatPathPrefixes(displayPath)}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 8,
|
||||
bottom: 4,
|
||||
)
|
||||
: EdgeInsets.zero,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(message.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
if (message.repeatCount > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
Icons.repeat,
|
||||
size: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'${message.repeatCount}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (isOutgoing) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
message.status == ChannelMessageStatus.sent
|
||||
? Icons.check
|
||||
: message.status ==
|
||||
ChannelMessageStatus.pending
|
||||
? Icons.schedule
|
||||
: Icons.error_outline,
|
||||
size: 14,
|
||||
color: message.status == ChannelMessageStatus.failed
|
||||
? Colors.red
|
||||
: Colors.grey[600],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
size: 14,
|
||||
color:
|
||||
message.status ==
|
||||
ChannelMessageStatus.failed
|
||||
? Colors.red
|
||||
: Colors.grey[600],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (message.reactions.isNotEmpty) ...[
|
||||
@@ -444,7 +484,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
children: [
|
||||
Icon(Icons.location_on_outlined, size: 14, color: previewTextColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(context.l10n.chat_location, style: TextStyle(fontSize: 12, color: previewTextColor)),
|
||||
Text(
|
||||
context.l10n.chat_location,
|
||||
style: TextStyle(fontSize: 12, color: previewTextColor),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
@@ -468,10 +511,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: colorScheme.primary,
|
||||
width: 3,
|
||||
),
|
||||
left: BorderSide(color: colorScheme.primary, width: 3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
@@ -509,17 +549,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
emoji,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Text(emoji, style: const TextStyle(fontSize: 16)),
|
||||
if (count > 1) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
@@ -546,7 +585,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
|
||||
_PoiInfo? _parsePoiMessage(String text) {
|
||||
final trimmed = text.trim();
|
||||
final match = RegExp(r'm:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|').firstMatch(trimmed);
|
||||
final match = RegExp(
|
||||
r'm:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|',
|
||||
).firstMatch(trimmed);
|
||||
if (match == null) return null;
|
||||
final lat = double.tryParse(match.group(1) ?? '');
|
||||
final lon = double.tryParse(match.group(2) ?? '');
|
||||
@@ -557,10 +598,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
|
||||
Widget _buildPoiMessage(BuildContext context, _PoiInfo poi, bool isOutgoing) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textColor =
|
||||
isOutgoing ? colorScheme.onPrimaryContainer : colorScheme.onSurface;
|
||||
final textColor = isOutgoing
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurface;
|
||||
final metaColor = textColor.withValues(alpha: 0.7);
|
||||
final channelColor = widget.channel.isPublicChannel ? Colors.orange : Colors.blue;
|
||||
final channelColor = widget.channel.isPublicChannel
|
||||
? Colors.orange
|
||||
: Colors.blue;
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
@@ -588,18 +632,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.chat_poiShared,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (poi.label.isNotEmpty)
|
||||
Text(
|
||||
poi.label,
|
||||
style: TextStyle(
|
||||
color: metaColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
style: TextStyle(color: metaColor, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -676,10 +714,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -708,7 +743,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer.withValues(alpha: 0.7),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSecondaryContainer.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -746,73 +783,76 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
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 = _parseGifId(value.text);
|
||||
if (gifId != null) {
|
||||
return 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,
|
||||
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 = _parseGifId(value.text);
|
||||
if (gifId != null) {
|
||||
return 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => _textController.clear(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return TextField(
|
||||
controller: _textController,
|
||||
focusNode: _textFieldFocusNode,
|
||||
inputFormatters: [
|
||||
Utf8LengthLimitingTextInputFormatter(maxBytes),
|
||||
],
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.chat_typeMessage,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => _textController.clear(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return TextField(
|
||||
controller: _textController,
|
||||
focusNode: _textFieldFocusNode,
|
||||
inputFormatters: [
|
||||
Utf8LengthLimitingTextInputFormatter(maxBytes),
|
||||
],
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.chat_typeMessage,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
maxLines: null,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: _sendMessage,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
maxLines: null,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: _sendMessage,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -932,24 +972,28 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
final emojiIndex = ReactionHelper.emojiToIndex(emoji);
|
||||
if (emojiIndex == null) return; // Unknown emoji, skip
|
||||
final timestampSecs = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
||||
final hash = ReactionHelper.computeReactionHash(timestampSecs, message.senderName, message.text);
|
||||
final hash = ReactionHelper.computeReactionHash(
|
||||
timestampSecs,
|
||||
message.senderName,
|
||||
message.text,
|
||||
);
|
||||
final reactionText = 'r:$hash:$emojiIndex';
|
||||
connector.sendChannelMessage(widget.channel, reactionText);
|
||||
}
|
||||
|
||||
void _copyMessageText(String text) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.chat_messageCopied)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageCopied)));
|
||||
}
|
||||
|
||||
Future<void> _deleteMessage(ChannelMessage message) async {
|
||||
await context.read<MeshCoreConnector>().deleteChannelMessage(message);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.chat_messageDeleted)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageDeleted)));
|
||||
}
|
||||
|
||||
String _formatPathPrefixes(Uint8List pathBytes) {
|
||||
@@ -964,9 +1008,5 @@ class _PoiInfo {
|
||||
final double lon;
|
||||
final String label;
|
||||
|
||||
const _PoiInfo({
|
||||
required this.lat,
|
||||
required this.lon,
|
||||
required this.label,
|
||||
});
|
||||
const _PoiInfo({required this.lat, required this.lon, required this.label});
|
||||
}
|
||||
|
||||
@@ -17,17 +17,17 @@ import '../models/contact.dart';
|
||||
class ChannelMessagePathScreen extends StatelessWidget {
|
||||
final ChannelMessage message;
|
||||
|
||||
const ChannelMessagePathScreen({
|
||||
super.key,
|
||||
required this.message,
|
||||
});
|
||||
const ChannelMessagePathScreen({super.key, required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final l10n = context.l10n;
|
||||
final primaryPath = _selectPrimaryPath(message.pathBytes, message.pathVariants);
|
||||
final primaryPath = _selectPrimaryPath(
|
||||
message.pathBytes,
|
||||
message.pathVariants,
|
||||
);
|
||||
final hops = _buildPathHops(primaryPath, connector.contacts, l10n);
|
||||
final hasHopDetails = primaryPath.isNotEmpty;
|
||||
final observedLabel = _formatObservedHops(
|
||||
@@ -88,10 +88,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryCard(
|
||||
BuildContext context, {
|
||||
String? observedLabel,
|
||||
}) {
|
||||
Widget _buildSummaryCard(BuildContext context, {String? observedLabel}) {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
child: Padding(
|
||||
@@ -105,21 +102,28 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildDetailRow(l10n.channelPath_senderLabel, message.senderName),
|
||||
_buildDetailRow(l10n.channelPath_timeLabel, _formatTime(message.timestamp, l10n)),
|
||||
_buildDetailRow(
|
||||
l10n.channelPath_timeLabel,
|
||||
_formatTime(message.timestamp, l10n),
|
||||
),
|
||||
if (message.repeatCount > 0)
|
||||
_buildDetailRow(l10n.channelPath_repeatsLabel, message.repeatCount.toString()),
|
||||
_buildDetailRow(l10n.channelPath_pathLabelTitle, _formatPathLabel(message.pathLength, l10n)),
|
||||
if (observedLabel != null) _buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
|
||||
_buildDetailRow(
|
||||
l10n.channelPath_repeatsLabel,
|
||||
message.repeatCount.toString(),
|
||||
),
|
||||
_buildDetailRow(
|
||||
l10n.channelPath_pathLabelTitle,
|
||||
_formatPathLabel(message.pathLength, l10n),
|
||||
),
|
||||
if (observedLabel != null)
|
||||
_buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPathVariants(
|
||||
BuildContext context,
|
||||
List<Uint8List> variants,
|
||||
) {
|
||||
Widget _buildPathVariants(BuildContext context, List<Uint8List> variants) {
|
||||
final l10n = context.l10n;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -163,7 +167,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
subtitle: Text(
|
||||
hop.hasLocation
|
||||
? '${hop.position!.latitude.toStringAsFixed(5)}, '
|
||||
'${hop.position!.longitude.toStringAsFixed(5)}'
|
||||
'${hop.position!.longitude.toStringAsFixed(5)}'
|
||||
: l10n.channelPath_noLocationData,
|
||||
),
|
||||
),
|
||||
@@ -239,7 +243,6 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ChannelMessagePathMapScreen extends StatefulWidget {
|
||||
@@ -257,7 +260,8 @@ class ChannelMessagePathMapScreen extends StatefulWidget {
|
||||
_ChannelMessagePathMapScreenState();
|
||||
}
|
||||
|
||||
class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScreen> {
|
||||
class _ChannelMessagePathMapScreenState
|
||||
extends State<ChannelMessagePathMapScreen> {
|
||||
Uint8List? _selectedPath;
|
||||
|
||||
@override
|
||||
@@ -270,8 +274,10 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
void didUpdateWidget(ChannelMessagePathMapScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.message != widget.message ||
|
||||
!_pathsEqual(oldWidget.initialPath ?? Uint8List(0),
|
||||
widget.initialPath ?? Uint8List(0))) {
|
||||
!_pathsEqual(
|
||||
oldWidget.initialPath ?? Uint8List(0),
|
||||
widget.initialPath ?? Uint8List(0),
|
||||
)) {
|
||||
_selectedPath = widget.initialPath;
|
||||
}
|
||||
}
|
||||
@@ -281,17 +287,25 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final tileCache = context.read<MapTileCacheService>();
|
||||
final primaryPath =
|
||||
_selectPrimaryPath(widget.message.pathBytes, widget.message.pathVariants);
|
||||
final observedPaths =
|
||||
_buildObservedPaths(primaryPath, widget.message.pathVariants);
|
||||
final primaryPath = _selectPrimaryPath(
|
||||
widget.message.pathBytes,
|
||||
widget.message.pathVariants,
|
||||
);
|
||||
final observedPaths = _buildObservedPaths(
|
||||
primaryPath,
|
||||
widget.message.pathVariants,
|
||||
);
|
||||
final selectedPath = _resolveSelectedPath(
|
||||
_selectedPath,
|
||||
observedPaths,
|
||||
primaryPath,
|
||||
);
|
||||
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
||||
final hops = _buildPathHops(selectedPath, connector.contacts, context.l10n);
|
||||
final hops = _buildPathHops(
|
||||
selectedPath,
|
||||
connector.contacts,
|
||||
context.l10n,
|
||||
);
|
||||
final points = hops
|
||||
.where((hop) => hop.hasLocation)
|
||||
.map((hop) => hop.position!)
|
||||
@@ -306,16 +320,17 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
]
|
||||
: <Polyline>[];
|
||||
|
||||
final initialCenter =
|
||||
points.isNotEmpty ? points.first : const LatLng(0, 0);
|
||||
final initialCenter = points.isNotEmpty
|
||||
? points.first
|
||||
: const LatLng(0, 0);
|
||||
final initialZoom = points.isNotEmpty ? 13.0 : 2.0;
|
||||
final bounds = points.length > 1 ? LatLngBounds.fromPoints(points) : null;
|
||||
final bounds = points.length > 1
|
||||
? LatLngBounds.fromPoints(points)
|
||||
: null;
|
||||
final mapKey = ValueKey(_formatPathPrefixes(selectedPath));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.channelPath_mapTitle),
|
||||
),
|
||||
appBar: AppBar(title: Text(context.l10n.channelPath_mapTitle)),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Stack(
|
||||
@@ -343,30 +358,28 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
MapTileCacheService.userAgentPackageName,
|
||||
maxZoom: 19,
|
||||
),
|
||||
if (polylines.isNotEmpty) PolylineLayer(polylines: polylines),
|
||||
MarkerLayer(
|
||||
markers: _buildHopMarkers(hops),
|
||||
),
|
||||
if (polylines.isNotEmpty)
|
||||
PolylineLayer(polylines: polylines),
|
||||
MarkerLayer(markers: _buildHopMarkers(hops)),
|
||||
],
|
||||
),
|
||||
if (observedPaths.length > 1)
|
||||
_buildPathSelector(
|
||||
context,
|
||||
observedPaths,
|
||||
selectedIndex,
|
||||
(index) {
|
||||
setState(() {
|
||||
_selectedPath = observedPaths[index].pathBytes;
|
||||
});
|
||||
},
|
||||
),
|
||||
_buildPathSelector(context, observedPaths, selectedIndex, (
|
||||
index,
|
||||
) {
|
||||
setState(() {
|
||||
_selectedPath = observedPaths[index].pathBytes;
|
||||
});
|
||||
}),
|
||||
if (points.isEmpty)
|
||||
Center(
|
||||
child: Card(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Text(context.l10n.channelPath_noRepeaterLocations),
|
||||
child: Text(
|
||||
context.l10n.channelPath_noRepeaterLocations,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -525,7 +538,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
subtitle: Text(
|
||||
hop.hasLocation
|
||||
? '${hop.position!.latitude.toStringAsFixed(5)}, '
|
||||
'${hop.position!.longitude.toStringAsFixed(5)}'
|
||||
'${hop.position!.longitude.toStringAsFixed(5)}'
|
||||
: l10n.channelPath_noLocationData,
|
||||
),
|
||||
);
|
||||
@@ -567,10 +580,7 @@ class _ObservedPath {
|
||||
final Uint8List pathBytes;
|
||||
final bool isPrimary;
|
||||
|
||||
const _ObservedPath({
|
||||
required this.pathBytes,
|
||||
required this.isPrimary,
|
||||
});
|
||||
const _ObservedPath({required this.pathBytes, required this.isPrimary});
|
||||
}
|
||||
|
||||
List<_PathHop> _buildPathHops(
|
||||
@@ -597,10 +607,12 @@ List<_PathHop> _buildPathHops(
|
||||
|
||||
Contact? _matchContactForPrefix(List<Contact> contacts, int prefix) {
|
||||
final matches = contacts
|
||||
.where((contact) =>
|
||||
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
|
||||
contact.publicKey.isNotEmpty &&
|
||||
contact.publicKey[0] == prefix)
|
||||
.where(
|
||||
(contact) =>
|
||||
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
|
||||
contact.publicKey.isNotEmpty &&
|
||||
contact.publicKey[0] == prefix,
|
||||
)
|
||||
.toList();
|
||||
if (matches.isEmpty) return null;
|
||||
|
||||
|
||||
@@ -154,7 +154,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SettingsScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -951,7 +953,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
dialogContext.l10n.community_communityHashtag,
|
||||
),
|
||||
subtitle: Text(
|
||||
dialogContext.l10n.community_communityHashtagDesc,
|
||||
dialogContext
|
||||
.l10n
|
||||
.community_communityHashtagDesc,
|
||||
),
|
||||
dense: true,
|
||||
),
|
||||
@@ -1047,7 +1051,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
hashtag = hashtag.substring(1);
|
||||
}
|
||||
final String channelName;
|
||||
|
||||
|
||||
final Uint8List psk;
|
||||
if (isRegularHashtag) {
|
||||
channelName = '#$hashtag';
|
||||
@@ -1069,8 +1073,10 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
);
|
||||
return;
|
||||
}
|
||||
channelName = '${selectedCommunity!.name} #$hashtag';
|
||||
psk = selectedCommunity!.deriveCommunityHashtagPsk(hashtag);
|
||||
channelName =
|
||||
'${selectedCommunity!.name} #$hashtag';
|
||||
psk = selectedCommunity!
|
||||
.deriveCommunityHashtagPsk(hashtag);
|
||||
// Track in community's hashtag list
|
||||
await _communityStore.addHashtagChannel(
|
||||
selectedCommunity!.id,
|
||||
|
||||
+340
-223
@@ -52,7 +52,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
_scrollController.onScrollNearTop = _loadOlderMessages;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
context.read<MeshCoreConnector>().setActiveContact(widget.contact.publicKeyHex);
|
||||
context.read<MeshCoreConnector>().setActiveContact(
|
||||
widget.contact.publicKeyHex,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -91,12 +93,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
title: Consumer2<PathHistoryService, MeshCoreConnector>(
|
||||
builder: (context, pathService, connector, _) {
|
||||
final contact = _resolveContact(connector);
|
||||
final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex);
|
||||
final unreadCount = connector.getUnreadCountForContactKey(
|
||||
widget.contact.publicKeyHex,
|
||||
);
|
||||
final unreadLabel = context.l10n.chat_unread(unreadCount);
|
||||
final pathLabel = _currentPathLabel(contact);
|
||||
|
||||
// Show path details if we have path data (from device or override)
|
||||
final hasPathData = contact.path.isNotEmpty || contact.pathOverrideBytes != null;
|
||||
final hasPathData =
|
||||
contact.path.isNotEmpty || contact.pathOverrideBytes != null;
|
||||
final effectivePath = contact.pathOverrideBytes ?? contact.path;
|
||||
|
||||
return Column(
|
||||
@@ -106,7 +111,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
Text(contact.name),
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: hasPathData ? () => _showFullPathDialog(context, effectivePath) : null,
|
||||
onTap: hasPathData
|
||||
? () => _showFullPathDialog(context, effectivePath)
|
||||
: null,
|
||||
child: Text(
|
||||
'$pathLabel • $unreadLabel',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -144,12 +151,20 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
value: 'auto',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
Icon(
|
||||
Icons.auto_mode,
|
||||
size: 20,
|
||||
color: !isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
context.l10n.chat_autoUseSavedPath,
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
fontWeight: !isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -159,12 +174,20 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
value: 'flood',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
Icon(
|
||||
Icons.waves,
|
||||
size: 20,
|
||||
color: isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
context.l10n.chat_forceFloodMode,
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
fontWeight: isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -196,9 +219,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
messages.isEmpty
|
||||
? _buildEmptyState()
|
||||
: _buildMessageList(messages, connector),
|
||||
JumpToBottomButton(
|
||||
scrollController: _scrollController,
|
||||
),
|
||||
JumpToBottomButton(scrollController: _scrollController),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -231,7 +252,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageList(List<Message> messages, MeshCoreConnector connector) {
|
||||
Widget _buildMessageList(
|
||||
List<Message> messages,
|
||||
MeshCoreConnector connector,
|
||||
) {
|
||||
// Reverse messages so newest appear at bottom with reverse: true
|
||||
final reversedMessages = messages.reversed.toList();
|
||||
final itemCount = reversedMessages.length + (_isLoadingOlder ? 1 : 0);
|
||||
@@ -267,14 +291,21 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
if (widget.contact.type == advTypeRoom) {
|
||||
contact = _resolveContactFrom4Bytes(
|
||||
connector,
|
||||
message.fourByteRoomContactKey.isEmpty ? Uint8List.fromList([0, 0, 0, 0]) : message.fourByteRoomContactKey,
|
||||
message.fourByteRoomContactKey.isEmpty
|
||||
? Uint8List.fromList([0, 0, 0, 0])
|
||||
: message.fourByteRoomContactKey,
|
||||
);
|
||||
fourByteHex = message.fourByteRoomContactKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase();
|
||||
fourByteHex = message.fourByteRoomContactKey
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
return _MessageBubble(
|
||||
message: message,
|
||||
senderName: widget.contact.type == advTypeRoom ? "${contact.name} [$fourByteHex]" : contact.name,
|
||||
senderName: widget.contact.type == advTypeRoom
|
||||
? "${contact.name} [$fourByteHex]"
|
||||
: contact.name,
|
||||
isRoomServer: widget.contact.type == advTypeRoom,
|
||||
onTap: () => _openMessagePath(message, contact),
|
||||
onLongPress: () => _showMessageActions(message, contact),
|
||||
@@ -290,9 +321,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(color: Theme.of(context).dividerColor),
|
||||
),
|
||||
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Row(
|
||||
@@ -314,10 +343,12 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
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),
|
||||
url:
|
||||
'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor:
|
||||
colorScheme.surfaceContainerHighest,
|
||||
fallbackTextColor: colorScheme.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
maxSize: 160,
|
||||
),
|
||||
),
|
||||
@@ -341,7 +372,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.chat_typeMessage,
|
||||
border: const OutlineInputBorder(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(connector),
|
||||
@@ -390,14 +424,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
connector.sendMessage(
|
||||
widget.contact,
|
||||
text,
|
||||
);
|
||||
connector.sendMessage(widget.contact, text);
|
||||
_textController.clear();
|
||||
}
|
||||
|
||||
|
||||
void _showPathHistory(BuildContext context) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
|
||||
@@ -422,13 +452,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
if (paths.isNotEmpty) ...[
|
||||
Text(
|
||||
context.l10n.chat_recentAckPaths,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (paths.length >= 100) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber[100],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -447,7 +483,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
dense: true,
|
||||
leading: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: path.wasFloodDiscovery ? Colors.blue : Colors.green,
|
||||
backgroundColor: path.wasFloodDiscovery
|
||||
? Colors.blue
|
||||
: Colors.green,
|
||||
child: Text(
|
||||
'${path.hopCount}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
@@ -475,23 +513,36 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
},
|
||||
),
|
||||
path.wasFloodDiscovery
|
||||
? const Icon(Icons.waves, size: 16, color: Colors.grey)
|
||||
: const Icon(Icons.route, size: 16, color: Colors.grey),
|
||||
? const Icon(
|
||||
Icons.waves,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.route,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
onLongPress: () => _showFullPathDialog(context, path.pathBytes),
|
||||
onLongPress: () =>
|
||||
_showFullPathDialog(context, path.pathBytes),
|
||||
onTap: () async {
|
||||
if (path.pathBytes.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.chat_pathDetailsNotAvailable),
|
||||
content: Text(
|
||||
context.l10n.chat_pathDetailsNotAvailable,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final pathBytes = Uint8List.fromList(path.pathBytes);
|
||||
final pathBytes = Uint8List.fromList(
|
||||
path.pathBytes,
|
||||
);
|
||||
final pathLength = path.pathBytes.length;
|
||||
|
||||
// Set the path override to persist user's choice
|
||||
@@ -521,7 +572,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.chat_pathActions,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
@@ -531,8 +585,14 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
backgroundColor: Colors.purple,
|
||||
child: Icon(Icons.edit_road, size: 16),
|
||||
),
|
||||
title: Text(context.l10n.chat_setCustomPath, style: const TextStyle(fontSize: 14)),
|
||||
subtitle: Text(context.l10n.chat_setCustomPathSubtitle, style: const TextStyle(fontSize: 11)),
|
||||
title: Text(
|
||||
context.l10n.chat_setCustomPath,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
context.l10n.chat_setCustomPathSubtitle,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showCustomPathDialog(context);
|
||||
@@ -545,8 +605,14 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
backgroundColor: Colors.orange,
|
||||
child: Icon(Icons.clear_all, size: 16),
|
||||
),
|
||||
title: Text(context.l10n.chat_clearPath, style: const TextStyle(fontSize: 14)),
|
||||
subtitle: Text(context.l10n.chat_clearPathSubtitle, style: const TextStyle(fontSize: 11)),
|
||||
title: Text(
|
||||
context.l10n.chat_clearPath,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
context.l10n.chat_clearPathSubtitle,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
onTap: () async {
|
||||
await connector.clearContactPath(widget.contact);
|
||||
if (!context.mounted) return;
|
||||
@@ -566,10 +632,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
backgroundColor: Colors.blue,
|
||||
child: Icon(Icons.waves, size: 16),
|
||||
),
|
||||
title: Text(context.l10n.chat_forceFloodMode, style: const TextStyle(fontSize: 14)),
|
||||
subtitle: Text(context.l10n.chat_floodModeSubtitle, style: const TextStyle(fontSize: 11)),
|
||||
title: Text(
|
||||
context.l10n.chat_forceFloodMode,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
context.l10n.chat_floodModeSubtitle,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
onTap: () async {
|
||||
await connector.setPathOverride(widget.contact, pathLen: -1);
|
||||
await connector.setPathOverride(
|
||||
widget.contact,
|
||||
pathLen: -1,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -598,7 +673,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
String _formatRelativeTime(DateTime time) {
|
||||
final diff = DateTime.now().difference(time);
|
||||
if (diff.inSeconds < 60) return context.l10n.time_justNow;
|
||||
if (diff.inMinutes < 60) return context.l10n.time_minutesAgo(diff.inMinutes);
|
||||
if (diff.inMinutes < 60)
|
||||
return context.l10n.time_minutesAgo(diff.inMinutes);
|
||||
if (diff.inHours < 24) return context.l10n.time_hoursAgo(diff.inHours);
|
||||
return context.l10n.time_daysAgo(diff.inDays);
|
||||
}
|
||||
@@ -640,7 +716,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Contact _resolveContactFrom4Bytes(MeshCoreConnector connector, Uint8List key4Bytes) {
|
||||
Contact _resolveContactFrom4Bytes(
|
||||
MeshCoreConnector connector,
|
||||
Uint8List key4Bytes,
|
||||
) {
|
||||
return connector.contacts.firstWhere(
|
||||
(c) => listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)),
|
||||
orElse: () => widget.contact,
|
||||
@@ -674,12 +753,12 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
|
||||
final status = !connector.isConnected
|
||||
? context.l10n.chat_pathSavedLocally
|
||||
: (verified ? context.l10n.chat_pathDeviceConfirmed : context.l10n.chat_pathDeviceNotConfirmed);
|
||||
: (verified
|
||||
? context.l10n.chat_pathDeviceConfirmed
|
||||
: context.l10n.chat_pathDeviceNotConfirmed);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.chat_pathSetHops(hopCount, status),
|
||||
),
|
||||
content: Text(context.l10n.chat_pathSetHops(hopCount, status)),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
@@ -694,7 +773,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
builder: (context) => Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final contact = _resolveContact(connector);
|
||||
final smazEnabled = connector.isContactSmazEnabled(contact.publicKeyHex);
|
||||
final smazEnabled = connector.isContactSmazEnabled(
|
||||
contact.publicKeyHex,
|
||||
);
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(contact.name),
|
||||
@@ -710,7 +791,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
context.l10n.chat_location,
|
||||
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
|
||||
),
|
||||
_buildInfoRow(context.l10n.chat_publicKey, '${contact.publicKeyHex.substring(0, 16)}...'),
|
||||
_buildInfoRow(
|
||||
context.l10n.chat_publicKey,
|
||||
'${contact.publicKeyHex.substring(0, 16)}...',
|
||||
),
|
||||
const Divider(),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
@@ -718,7 +802,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
subtitle: Text(context.l10n.chat_compressOutgoingMessages),
|
||||
value: smazEnabled,
|
||||
onChanged: (value) {
|
||||
connector.setContactSmazEnabled(contact.publicKeyHex, value);
|
||||
connector.setContactSmazEnabled(
|
||||
contact.publicKeyHex,
|
||||
value,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -765,7 +852,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
|
||||
final currentContact = _resolveContact(connector);
|
||||
if (currentContact.pathLength > 0 && currentContact.path.isEmpty && connector.isConnected) {
|
||||
if (currentContact.pathLength > 0 &&
|
||||
currentContact.path.isEmpty &&
|
||||
connector.isConnected) {
|
||||
connector.getContacts();
|
||||
}
|
||||
|
||||
@@ -786,19 +875,31 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
onRefresh: connector.isConnected ? connector.getContacts : null,
|
||||
);
|
||||
|
||||
appLogger.info('PathSelectionDialog returned: ${result?.length ?? 0} bytes, mounted: $mounted', tag: 'ChatScreen');
|
||||
appLogger.info(
|
||||
'PathSelectionDialog returned: ${result?.length ?? 0} bytes, mounted: $mounted',
|
||||
tag: 'ChatScreen',
|
||||
);
|
||||
|
||||
if (result == null) {
|
||||
appLogger.info('PathSelectionDialog was cancelled or returned null', tag: 'ChatScreen');
|
||||
appLogger.info(
|
||||
'PathSelectionDialog was cancelled or returned null',
|
||||
tag: 'ChatScreen',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
appLogger.warn('Widget not mounted after dialog, cannot set path', tag: 'ChatScreen');
|
||||
appLogger.warn(
|
||||
'Widget not mounted after dialog, cannot set path',
|
||||
tag: 'ChatScreen',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
appLogger.info('Calling setPathOverride for ${widget.contact.name}', tag: 'ChatScreen');
|
||||
appLogger.info(
|
||||
'Calling setPathOverride for ${widget.contact.name}',
|
||||
tag: 'ChatScreen',
|
||||
);
|
||||
await connector.setPathOverride(
|
||||
widget.contact,
|
||||
pathLen: result.length,
|
||||
@@ -810,7 +911,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
await _notifyPathSet(connector, widget.contact, result, result.length);
|
||||
}
|
||||
|
||||
|
||||
void _openMessagePath(Message message, Contact contact) {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final fourByteHex = message.fourByteRoomContactKey
|
||||
@@ -877,8 +977,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
await _deleteMessage(message);
|
||||
},
|
||||
),
|
||||
if (message.isOutgoing &&
|
||||
message.status == MessageStatus.failed)
|
||||
if (message.isOutgoing && message.status == MessageStatus.failed)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.refresh),
|
||||
title: Text(context.l10n.common_retry),
|
||||
@@ -909,29 +1008,26 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
|
||||
void _copyMessageText(String text) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.chat_messageCopied)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageCopied)));
|
||||
}
|
||||
|
||||
Future<void> _deleteMessage(Message message) async {
|
||||
await context.read<MeshCoreConnector>().deleteMessage(message);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.chat_messageDeleted)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageDeleted)));
|
||||
}
|
||||
|
||||
void _retryMessage(Message message) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
// Retry using the contact's current path override setting
|
||||
connector.sendMessage(
|
||||
widget.contact,
|
||||
message.text,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.chat_retryingMessage)),
|
||||
);
|
||||
connector.sendMessage(widget.contact, message.text);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
|
||||
}
|
||||
|
||||
void _showEmojiPicker(Message message, Contact senderContact) {
|
||||
@@ -951,11 +1047,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final emojiIndex = ReactionHelper.emojiToIndex(emoji);
|
||||
if (emojiIndex == null) return; // Unknown emoji, skip
|
||||
final timestampSecs = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
|
||||
// For room servers, include sender name (like channels) since multiple users
|
||||
// For 1:1 chats, sender is implicit (null)
|
||||
final senderName = widget.contact.type == advTypeRoom ? senderContact.name : null;
|
||||
final hash = ReactionHelper.computeReactionHash(timestampSecs, senderName, message.text);
|
||||
final senderName = widget.contact.type == advTypeRoom
|
||||
? senderContact.name
|
||||
: null;
|
||||
final hash = ReactionHelper.computeReactionHash(
|
||||
timestampSecs,
|
||||
senderName,
|
||||
message.text,
|
||||
);
|
||||
final reactionText = 'r:$hash:$emojiIndex';
|
||||
connector.sendMessage(widget.contact, reactionText);
|
||||
}
|
||||
@@ -985,7 +1087,9 @@ class _MessageBubble extends StatelessWidget {
|
||||
final isFailed = message.status == MessageStatus.failed;
|
||||
final bubbleColor = isFailed
|
||||
? colorScheme.errorContainer
|
||||
: (isOutgoing ? colorScheme.primary : colorScheme.surfaceContainerHighest);
|
||||
: (isOutgoing
|
||||
? colorScheme.primary
|
||||
: colorScheme.surfaceContainerHighest);
|
||||
final textColor = isFailed
|
||||
? colorScheme.onErrorContainer
|
||||
: (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface);
|
||||
@@ -997,13 +1101,17 @@ class _MessageBubble extends StatelessWidget {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
crossAxisAlignment: isOutgoing
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: Row(
|
||||
mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
mainAxisAlignment: isOutgoing
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOutgoing) ...[
|
||||
@@ -1012,133 +1120,154 @@ class _MessageBubble extends StatelessWidget {
|
||||
],
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.all(4)
|
||||
: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.65,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: bubbleColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOutgoing) ...[
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.only(left: 8, top: 4, bottom: 4)
|
||||
: EdgeInsets.zero,
|
||||
child: Text(
|
||||
senderName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.all(4)
|
||||
: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (gifId == null) const SizedBox(height: 4),
|
||||
],
|
||||
if (poi != null)
|
||||
_buildPoiMessage(context, poi, textColor, metaColor)
|
||||
else if (gifId != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: GifMessage(
|
||||
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: Colors.transparent,
|
||||
fallbackTextColor: textColor.withValues(alpha: 0.7),
|
||||
),
|
||||
)
|
||||
else
|
||||
Linkify(
|
||||
text: messageText,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
),
|
||||
linkStyle: const TextStyle(
|
||||
color: Colors.green,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
options: const LinkifyOptions(
|
||||
humanize: false,
|
||||
defaultToHttps: false,
|
||||
),
|
||||
linkifiers: const [UrlLinkifier()],
|
||||
onOpen: (link) => LinkHandler.handleLinkTap(context, link.url),
|
||||
),
|
||||
if (isOutgoing && message.retryCount > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.symmetric(horizontal: 8)
|
||||
: EdgeInsets.zero,
|
||||
child: Text(
|
||||
context.l10n.chat_retryCount(message.retryCount, 4),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: metaColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.only(left: 8, right: 8, bottom: 4)
|
||||
: EdgeInsets.zero,
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(message.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: metaColor,
|
||||
),
|
||||
),
|
||||
if (isOutgoing) ...[
|
||||
const SizedBox(width: 4),
|
||||
_buildStatusIcon(metaColor),
|
||||
],
|
||||
if (message.tripTimeMs != null &&
|
||||
message.status == MessageStatus.delivered) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.speed,
|
||||
size: 10,
|
||||
color: isOutgoing ? metaColor : Colors.green[700],
|
||||
),
|
||||
Text(
|
||||
'${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s',
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.65,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: bubbleColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOutgoing) ...[
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.only(
|
||||
left: 8,
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
)
|
||||
: EdgeInsets.zero,
|
||||
child: Text(
|
||||
senderName,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: isOutgoing ? metaColor : Colors.green[700],
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (gifId == null) const SizedBox(height: 4),
|
||||
],
|
||||
),
|
||||
if (poi != null)
|
||||
_buildPoiMessage(context, poi, textColor, metaColor)
|
||||
else if (gifId != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: GifMessage(
|
||||
url:
|
||||
'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: Colors.transparent,
|
||||
fallbackTextColor: textColor.withValues(
|
||||
alpha: 0.7,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Linkify(
|
||||
text: messageText,
|
||||
style: TextStyle(color: textColor),
|
||||
linkStyle: const TextStyle(
|
||||
color: Colors.green,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
options: const LinkifyOptions(
|
||||
humanize: false,
|
||||
defaultToHttps: false,
|
||||
),
|
||||
linkifiers: const [UrlLinkifier()],
|
||||
onOpen: (link) =>
|
||||
LinkHandler.handleLinkTap(context, link.url),
|
||||
),
|
||||
if (isOutgoing && message.retryCount > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.symmetric(horizontal: 8)
|
||||
: EdgeInsets.zero,
|
||||
child: Text(
|
||||
context.l10n.chat_retryCount(
|
||||
message.retryCount,
|
||||
4,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: metaColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 8,
|
||||
bottom: 4,
|
||||
)
|
||||
: EdgeInsets.zero,
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(message.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: metaColor,
|
||||
),
|
||||
),
|
||||
if (isOutgoing) ...[
|
||||
const SizedBox(width: 4),
|
||||
_buildStatusIcon(metaColor),
|
||||
],
|
||||
if (message.tripTimeMs != null &&
|
||||
message.status ==
|
||||
MessageStatus.delivered) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.speed,
|
||||
size: 10,
|
||||
color: isOutgoing
|
||||
? metaColor
|
||||
: Colors.green[700],
|
||||
),
|
||||
Text(
|
||||
'${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: isOutgoing
|
||||
? metaColor
|
||||
: Colors.green[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (message.reactions.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
|
||||
child: _buildReactionsDisplay(context, message, colorScheme),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (message.reactions.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
|
||||
child: _buildReactionsDisplay(context, message, colorScheme),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1151,8 +1280,9 @@ class _MessageBubble extends StatelessWidget {
|
||||
|
||||
_PoiInfo? _parsePoiMessage(String text) {
|
||||
final trimmed = text.trim();
|
||||
final match = RegExp(r'^m:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|.*$')
|
||||
.firstMatch(trimmed);
|
||||
final match = RegExp(
|
||||
r'^m:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|.*$',
|
||||
).firstMatch(trimmed);
|
||||
if (match == null) return null;
|
||||
final lat = double.tryParse(match.group(1) ?? '');
|
||||
final lon = double.tryParse(match.group(2) ?? '');
|
||||
@@ -1193,18 +1323,12 @@ class _MessageBubble extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.chat_poiShared,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (poi.label.isNotEmpty)
|
||||
Text(
|
||||
poi.label,
|
||||
style: TextStyle(
|
||||
color: metaColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
style: TextStyle(color: metaColor, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1213,7 +1337,11 @@ class _MessageBubble extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReactionsDisplay(BuildContext context, Message message, ColorScheme colorScheme) {
|
||||
Widget _buildReactionsDisplay(
|
||||
BuildContext context,
|
||||
Message message,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
@@ -1234,10 +1362,7 @@ class _MessageBubble extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
emoji,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Text(emoji, style: const TextStyle(fontSize: 16)),
|
||||
if (count > 1) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
@@ -1321,11 +1446,7 @@ class _MessageBubble extends StatelessWidget {
|
||||
break;
|
||||
}
|
||||
|
||||
return Icon(
|
||||
icon,
|
||||
size: 12,
|
||||
color: color,
|
||||
);
|
||||
return Icon(icon, size: 12, color: color);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
@@ -1340,9 +1461,5 @@ class _PoiInfo {
|
||||
final double lon;
|
||||
final String label;
|
||||
|
||||
const _PoiInfo({
|
||||
required this.lat,
|
||||
required this.lon,
|
||||
required this.label,
|
||||
});
|
||||
const _PoiInfo({required this.lat, required this.lon, required this.label});
|
||||
}
|
||||
|
||||
+153
-116
@@ -29,16 +29,9 @@ import 'map_screen.dart';
|
||||
import 'repeater_hub_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
enum RoomLoginDestination {
|
||||
chat,
|
||||
management,
|
||||
}
|
||||
enum RoomLoginDestination { chat, management }
|
||||
|
||||
enum ContactOperationType {
|
||||
import,
|
||||
export,
|
||||
zeroHopShare,
|
||||
}
|
||||
enum ContactOperationType { import, export, zeroHopShare }
|
||||
|
||||
class ContactsScreen extends StatefulWidget {
|
||||
final bool hideBackButton;
|
||||
@@ -105,7 +98,9 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
if (advertPacket.length < 98) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.contacts_invalidAdvertFormat),
|
||||
),
|
||||
);
|
||||
}
|
||||
_pendingOperations.remove(ContactOperationType.export);
|
||||
@@ -115,23 +110,25 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
|
||||
}
|
||||
|
||||
if(code == respCodeOk) {
|
||||
if (code == respCodeOk) {
|
||||
// Show a snackbar indicating success
|
||||
if(!mounted) return;
|
||||
if (!mounted) return;
|
||||
|
||||
if(_pendingOperations.contains(ContactOperationType.import)){
|
||||
if (_pendingOperations.contains(ContactOperationType.import)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_contactImported)),
|
||||
);
|
||||
}
|
||||
|
||||
if(_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
|
||||
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_zeroHopContactAdvertSent)),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.contacts_zeroHopContactAdvertSent),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if(_pendingOperations.contains(ContactOperationType.export)) {
|
||||
if (_pendingOperations.contains(ContactOperationType.export)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_contactAdvertCopied)),
|
||||
);
|
||||
@@ -140,30 +137,33 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
_pendingOperations.clear();
|
||||
}
|
||||
|
||||
if(code == respCodeErr) {
|
||||
if (code == respCodeErr) {
|
||||
// Show a snackbar indicating failure
|
||||
if(!mounted) return;
|
||||
if (!mounted) return;
|
||||
|
||||
if(_pendingOperations.contains(ContactOperationType.import)){
|
||||
if (_pendingOperations.contains(ContactOperationType.import)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_contactImportFailed)),
|
||||
);
|
||||
}
|
||||
|
||||
if(_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
|
||||
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_zeroHopContactAdvertFailed)),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.contacts_zeroHopContactAdvertFailed),
|
||||
),
|
||||
);
|
||||
}
|
||||
if(_pendingOperations.contains(ContactOperationType.export)) {
|
||||
if (_pendingOperations.contains(ContactOperationType.export)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_contactAdvertCopyFailed)),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.contacts_contactAdvertCopyFailed),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_pendingOperations.clear();
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final clipboardData = await Clipboard.getData('text/plain');
|
||||
if (clipboardData == null || clipboardData.text == null) {
|
||||
if(mounted) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_clipboardEmpty)),
|
||||
);
|
||||
@@ -194,7 +194,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
}
|
||||
final text = clipboardData.text!.trim();
|
||||
if (!text.startsWith('meshcore://')) {
|
||||
if(mounted) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)),
|
||||
);
|
||||
@@ -207,7 +207,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
_pendingOperations.add(ContactOperationType.import);
|
||||
await connector.sendFrame(importContactFrame);
|
||||
} catch (e) {
|
||||
if(mounted) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)),
|
||||
);
|
||||
@@ -234,59 +234,64 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
centerTitle: true,
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
PopupMenuButton(itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.connect_without_contact),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.contacts_zeroHopAdvert),
|
||||
],
|
||||
),
|
||||
onTap: () => {
|
||||
PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.connect_without_contact),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.contacts_zeroHopAdvert),
|
||||
],
|
||||
),
|
||||
onTap: () => {
|
||||
connector.sendSelfAdvert(flood: false),
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.settings_advertisementSent))),
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.cell_tower),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.contacts_floodAdvert),
|
||||
],
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.settings_advertisementSent),
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
onTap: () => {
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.cell_tower),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.contacts_floodAdvert),
|
||||
],
|
||||
),
|
||||
onTap: () => {
|
||||
connector.sendSelfAdvert(flood: true),
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.settings_advertisementSent))),
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.copy),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.contacts_copyAdvertToClipboard),
|
||||
],
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.settings_advertisementSent),
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
onTap: () => _contactExport(Uint8List.fromList([])),
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.paste),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.contacts_addContactFromClipboard),
|
||||
],
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.copy),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.contacts_copyAdvertToClipboard),
|
||||
],
|
||||
),
|
||||
onTap: () => _contactExport(Uint8List.fromList([])),
|
||||
),
|
||||
onTap: () => _contactImport(),
|
||||
),
|
||||
],
|
||||
icon: const Icon(Icons.connect_without_contact),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.paste),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.contacts_addContactFromClipboard),
|
||||
],
|
||||
),
|
||||
onTap: () => _contactImport(),
|
||||
),
|
||||
],
|
||||
icon: const Icon(Icons.connect_without_contact),
|
||||
),
|
||||
PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
@@ -310,7 +315,9 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SettingsScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -704,7 +711,8 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => destination == RoomLoginDestination.management
|
||||
builder: (context) =>
|
||||
destination == RoomLoginDestination.management
|
||||
? RepeaterHubScreen(repeater: room, password: password)
|
||||
: ChatScreen(contact: room),
|
||||
),
|
||||
@@ -970,15 +978,22 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
if (isRepeater) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
title: contact.pathLength > 0 ? Text(context.l10n.contacts_pathTrace) : Text(context.l10n.contacts_ping),
|
||||
title: contact.pathLength > 0
|
||||
? Text(context.l10n.contacts_pathTrace)
|
||||
: Text(context.l10n.contacts_ping),
|
||||
onTap: () {
|
||||
showDialog(context: context, builder: (context) {
|
||||
return PathTraceDialog(
|
||||
title: contact.pathLength > 0 ? context.l10n.contacts_repeaterPathTrace : context.l10n.contacts_repeaterPing,
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
);
|
||||
});
|
||||
}
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return PathTraceDialog(
|
||||
title: contact.pathLength > 0
|
||||
? context.l10n.contacts_repeaterPathTrace
|
||||
: context.l10n.contacts_repeaterPing,
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cell_tower, color: Colors.orange),
|
||||
@@ -987,19 +1002,26 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
Navigator.pop(sheetContext);
|
||||
_showRepeaterLogin(context, contact);
|
||||
},
|
||||
)
|
||||
]else if (isRoom) ...[
|
||||
),
|
||||
] else if (isRoom) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
title: contact.pathLength > 0 ? Text(context.l10n.contacts_pathTrace) : Text(context.l10n.contacts_ping),
|
||||
title: contact.pathLength > 0
|
||||
? Text(context.l10n.contacts_pathTrace)
|
||||
: Text(context.l10n.contacts_ping),
|
||||
onTap: () {
|
||||
showDialog(context: context, builder: (context) {
|
||||
return PathTraceDialog(
|
||||
title: contact.pathLength > 0 ? context.l10n.contacts_roomPathTrace : context.l10n.contacts_roomPing,
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
);
|
||||
});
|
||||
}
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return PathTraceDialog(
|
||||
title: contact.pathLength > 0
|
||||
? context.l10n.contacts_roomPathTrace
|
||||
: context.l10n.contacts_roomPing,
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.room, color: Colors.blue),
|
||||
@@ -1010,27 +1032,39 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.room_preferences, color: Colors.orange),
|
||||
leading: const Icon(
|
||||
Icons.room_preferences,
|
||||
color: Colors.orange,
|
||||
),
|
||||
title: Text(context.l10n.room_management),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showRoomLogin(context, contact, RoomLoginDestination.management);
|
||||
_showRoomLogin(
|
||||
context,
|
||||
contact,
|
||||
RoomLoginDestination.management,
|
||||
);
|
||||
},
|
||||
),
|
||||
] else ...[
|
||||
if(contact.pathLength > 0)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
title: Text(context.l10n.contacts_chatTraceRoute),
|
||||
onTap: () {
|
||||
showDialog(context: context, builder: (context) {
|
||||
return PathTraceDialog(
|
||||
title: context.l10n.contacts_pathTraceTo(contact.name),
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
if (contact.pathLength > 0)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
title: Text(context.l10n.contacts_chatTraceRoute),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return PathTraceDialog(
|
||||
title: context.l10n.contacts_pathTraceTo(
|
||||
contact.name,
|
||||
),
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
),
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.chat),
|
||||
title: Text(context.l10n.contacts_openChat),
|
||||
@@ -1051,7 +1085,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
ListTile(
|
||||
leading: const Icon(Icons.connect_without_contact),
|
||||
title: Text(context.l10n.contacts_ShareContactZeroHop),
|
||||
onTap: () {
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_contactZeroHop(contact.publicKey);
|
||||
},
|
||||
@@ -1127,10 +1161,13 @@ class _ContactTile extends StatelessWidget {
|
||||
child: _buildContactAvatar(contact),
|
||||
),
|
||||
title: Text(contact.name),
|
||||
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(contact.pathLabel),
|
||||
Text(contact.shortPubKeyHex, style: TextStyle(fontSize: 12))
|
||||
],),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(contact.pathLabel),
|
||||
Text(contact.shortPubKeyHex, style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
// Clamp text scaling in trailing section to prevent overflow while
|
||||
// maintaining accessibility. Primary content (title/subtitle) scales normally.
|
||||
trailing: MediaQuery(
|
||||
@@ -1154,8 +1191,8 @@ class _ContactTile extends StatelessWidget {
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (contact.hasLocation)
|
||||
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
|
||||
if (contact.hasLocation)
|
||||
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -127,9 +127,7 @@ class _DeviceScreenState extends State<DeviceScreen>
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -207,7 +205,6 @@ class _DeviceScreenState extends State<DeviceScreen>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildBatteryIndicator(
|
||||
MeshCoreConnector connector,
|
||||
BuildContext context,
|
||||
@@ -224,11 +221,7 @@ class _DeviceScreenState extends State<DeviceScreen>
|
||||
final icon = _batteryIcon(percent);
|
||||
|
||||
return ActionChip(
|
||||
avatar: Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
avatar: Icon(icon, size: 16, color: colorScheme.onSecondaryContainer),
|
||||
label: Text(displayLabel),
|
||||
labelStyle: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
@@ -260,25 +253,19 @@ class _DeviceScreenState extends State<DeviceScreen>
|
||||
case 0:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(
|
||||
const ContactsScreen(hideBackButton: true),
|
||||
),
|
||||
buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)),
|
||||
);
|
||||
break;
|
||||
case 1:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(
|
||||
const ChannelsScreen(hideBackButton: true),
|
||||
),
|
||||
buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)),
|
||||
);
|
||||
break;
|
||||
case 2:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(
|
||||
const MapScreen(hideBackButton: true),
|
||||
),
|
||||
buildQuickSwitchRoute(const MapScreen(hideBackButton: true)),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -56,10 +56,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
_updateEstimate();
|
||||
if (bounds != null) {
|
||||
_mapController.fitCamera(
|
||||
CameraFit.bounds(
|
||||
bounds: bounds,
|
||||
padding: const EdgeInsets.all(48),
|
||||
),
|
||||
CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(48)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -72,8 +69,11 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
return;
|
||||
}
|
||||
final cacheService = context.read<MapTileCacheService>();
|
||||
final count =
|
||||
cacheService.estimateTileCount(_selectedBounds!, _minZoom, _maxZoom);
|
||||
final count = cacheService.estimateTileCount(
|
||||
_selectedBounds!,
|
||||
_minZoom,
|
||||
_maxZoom,
|
||||
);
|
||||
setState(() {
|
||||
_estimatedTiles = count;
|
||||
});
|
||||
@@ -181,9 +181,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
result.failed,
|
||||
)
|
||||
: context.l10n.mapCache_cachedTiles(result.downloaded);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
Future<void> _clearCache() async {
|
||||
@@ -224,10 +224,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
: (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.mapCache_title),
|
||||
centerTitle: true,
|
||||
),
|
||||
appBar: AppBar(title: Text(l10n.mapCache_title), centerTitle: true),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -290,7 +287,10 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
children: [
|
||||
Text(
|
||||
l10n.mapCache_cacheArea,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
@@ -304,8 +304,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
TextButton(
|
||||
onPressed:
|
||||
_isDownloading || selectedBounds == null ? null : _clearBounds,
|
||||
onPressed: _isDownloading || selectedBounds == null
|
||||
? null
|
||||
: _clearBounds,
|
||||
child: Text(l10n.common_clear),
|
||||
),
|
||||
],
|
||||
@@ -313,11 +314,16 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.mapCache_zoomRange,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
RangeSlider(
|
||||
values:
|
||||
RangeValues(_minZoom.toDouble(), _maxZoom.toDouble()),
|
||||
values: RangeValues(
|
||||
_minZoom.toDouble(),
|
||||
_maxZoom.toDouble(),
|
||||
),
|
||||
min: 3,
|
||||
max: 18,
|
||||
divisions: 15,
|
||||
@@ -341,10 +347,12 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(value: progressValue),
|
||||
const SizedBox(height: 4),
|
||||
Text(l10n.mapCache_downloadedTiles(
|
||||
_completedTiles,
|
||||
_estimatedTiles,
|
||||
)),
|
||||
Text(
|
||||
l10n.mapCache_downloadedTiles(
|
||||
_completedTiles,
|
||||
_estimatedTiles,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
|
||||
+78
-80
@@ -269,7 +269,9 @@ class _MapScreenState extends State<MapScreen> {
|
||||
),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SettingsScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -278,85 +280,82 @@ class _MapScreenState extends State<MapScreen> {
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: center,
|
||||
initialZoom: initialZoom,
|
||||
minZoom: 2.0,
|
||||
maxZoom: 18.0,
|
||||
interactionOptions: InteractionOptions(
|
||||
flags: ~InteractiveFlag.rotate
|
||||
),
|
||||
onTap: (_, latLng) {
|
||||
if (_isSelectingPoi) {
|
||||
setState(() {
|
||||
_isSelectingPoi = false;
|
||||
});
|
||||
_shareMarker(
|
||||
context: context,
|
||||
connector: connector,
|
||||
position: latLng,
|
||||
defaultLabel: context.l10n.map_pointOfInterest,
|
||||
flags: 'poi',
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPress: (_, latLng) {
|
||||
if (_isSelectingPoi) {
|
||||
setState(() {
|
||||
_isSelectingPoi = false;
|
||||
});
|
||||
_shareMarker(
|
||||
context: context,
|
||||
connector: connector,
|
||||
position: latLng,
|
||||
defaultLabel: context.l10n.map_pointOfInterest,
|
||||
flags: 'poi',
|
||||
);
|
||||
return;
|
||||
}
|
||||
_showShareMarkerAtPositionSheet(
|
||||
context: context,
|
||||
connector: connector,
|
||||
position: latLng,
|
||||
);
|
||||
},
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: kMapTileUrlTemplate,
|
||||
tileProvider: tileCache.tileProvider,
|
||||
userAgentPackageName:
|
||||
MapTileCacheService.userAgentPackageName,
|
||||
maxZoom: 19,
|
||||
),
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
if (highlightPosition != null)
|
||||
Marker(
|
||||
point: highlightPosition,
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Icon(
|
||||
Icons.location_on_outlined,
|
||||
color: Colors.red[600],
|
||||
size: 34,
|
||||
),
|
||||
),
|
||||
..._buildMarkers(contactsWithLocation, settings),
|
||||
...sharedMarkers.map(_buildSharedMarker),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
_buildLegend(
|
||||
contactsWithLocation.length,
|
||||
sharedMarkers.length,
|
||||
),
|
||||
],
|
||||
children: [
|
||||
FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: center,
|
||||
initialZoom: initialZoom,
|
||||
minZoom: 2.0,
|
||||
maxZoom: 18.0,
|
||||
interactionOptions: InteractionOptions(
|
||||
flags: ~InteractiveFlag.rotate,
|
||||
),
|
||||
onTap: (_, latLng) {
|
||||
if (_isSelectingPoi) {
|
||||
setState(() {
|
||||
_isSelectingPoi = false;
|
||||
});
|
||||
_shareMarker(
|
||||
context: context,
|
||||
connector: connector,
|
||||
position: latLng,
|
||||
defaultLabel: context.l10n.map_pointOfInterest,
|
||||
flags: 'poi',
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPress: (_, latLng) {
|
||||
if (_isSelectingPoi) {
|
||||
setState(() {
|
||||
_isSelectingPoi = false;
|
||||
});
|
||||
_shareMarker(
|
||||
context: context,
|
||||
connector: connector,
|
||||
position: latLng,
|
||||
defaultLabel: context.l10n.map_pointOfInterest,
|
||||
flags: 'poi',
|
||||
);
|
||||
return;
|
||||
}
|
||||
_showShareMarkerAtPositionSheet(
|
||||
context: context,
|
||||
connector: connector,
|
||||
position: latLng,
|
||||
);
|
||||
},
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: kMapTileUrlTemplate,
|
||||
tileProvider: tileCache.tileProvider,
|
||||
userAgentPackageName:
|
||||
MapTileCacheService.userAgentPackageName,
|
||||
maxZoom: 19,
|
||||
),
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
if (highlightPosition != null)
|
||||
Marker(
|
||||
point: highlightPosition,
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Icon(
|
||||
Icons.location_on_outlined,
|
||||
color: Colors.red[600],
|
||||
size: 34,
|
||||
),
|
||||
),
|
||||
..._buildMarkers(contactsWithLocation, settings),
|
||||
...sharedMarkers.map(_buildSharedMarker),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
_buildLegend(contactsWithLocation.length, sharedMarkers.length),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
child: QuickSwitchBar(
|
||||
@@ -376,7 +375,6 @@ class _MapScreenState extends State<MapScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
List<Marker> _buildMarkers(List<Contact> contacts, settings) {
|
||||
final markers = <Marker>[];
|
||||
|
||||
|
||||
@@ -119,14 +119,24 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
|
||||
// Show debug info if requested
|
||||
if (showDebug && mounted) {
|
||||
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command);
|
||||
DebugFrameViewer.showFrameDebug(context, frame, context.l10n.repeater_cliCommandFrameTitle);
|
||||
final frame = buildSendCliCommandFrame(
|
||||
widget.repeater.publicKey,
|
||||
command,
|
||||
);
|
||||
DebugFrameViewer.showFrameDebug(
|
||||
context,
|
||||
frame,
|
||||
context.l10n.repeater_cliCommandFrameTitle,
|
||||
);
|
||||
}
|
||||
|
||||
// Send CLI command to repeater with retry
|
||||
try {
|
||||
if (_commandService != null) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final connector = Provider.of<MeshCoreConnector>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final response = await _commandService!.sendCommand(
|
||||
repeater,
|
||||
@@ -230,7 +240,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
Text(l10n.repeater_cliTitle),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -251,12 +264,20 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
value: 'auto',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
Icon(
|
||||
Icons.auto_mode,
|
||||
size: 20,
|
||||
color: !isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_autoUseSavedPath,
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
fontWeight: !isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -266,12 +287,20 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
value: 'flood',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
Icon(
|
||||
Icons.waves,
|
||||
size: 20,
|
||||
color: isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_forceFloodMode,
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
fontWeight: isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -282,7 +311,8 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.timeline),
|
||||
tooltip: l10n.repeater_pathManagement,
|
||||
onPressed: () => PathManagementDialog.show(context, contact: repeater),
|
||||
onPressed: () =>
|
||||
PathManagementDialog.show(context, contact: repeater),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bug_report),
|
||||
@@ -473,7 +503,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.repeater_enterCommandHint,
|
||||
border: const OutlineInputBorder(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
prefixText: '> ',
|
||||
),
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
@@ -718,10 +751,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
];
|
||||
|
||||
final gpsCommands = [
|
||||
_CommandHelpEntry(
|
||||
command: 'gps',
|
||||
description: l10n.repeater_cliHelpGps,
|
||||
),
|
||||
_CommandHelpEntry(command: 'gps', description: l10n.repeater_cliHelpGps),
|
||||
_CommandHelpEntry(
|
||||
command: 'gps {on|off}',
|
||||
description: l10n.repeater_cliHelpGpsOnOff,
|
||||
@@ -758,13 +788,25 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(context, l10n.repeater_general, generalCommands),
|
||||
_buildHelpSection(
|
||||
context,
|
||||
l10n.repeater_general,
|
||||
generalCommands,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(context, l10n.repeater_settingsCategory, settingsCommands),
|
||||
_buildHelpSection(
|
||||
context,
|
||||
l10n.repeater_settingsCategory,
|
||||
settingsCommands,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(context, l10n.repeater_bridge, bridgeCommands),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(context, l10n.repeater_logging, loggingCommands),
|
||||
_buildHelpSection(
|
||||
context,
|
||||
l10n.repeater_logging,
|
||||
loggingCommands,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(
|
||||
context,
|
||||
@@ -813,10 +855,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
),
|
||||
if (note != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
note,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
Text(note, style: const TextStyle(fontSize: 12)),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
...commands.map((entry) => _buildHelpCommandCard(context, entry)),
|
||||
@@ -871,8 +910,5 @@ class _CommandHelpEntry {
|
||||
final String command;
|
||||
final String description;
|
||||
|
||||
const _CommandHelpEntry({
|
||||
required this.command,
|
||||
required this.description,
|
||||
});
|
||||
const _CommandHelpEntry({required this.command, required this.description});
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ class RepeaterStatusScreen extends StatefulWidget {
|
||||
class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
static const int _statusPayloadOffset = 8;
|
||||
static const int _statusStatsSize = 52;
|
||||
static const int _statusResponseBytes = _statusPayloadOffset + _statusStatsSize;
|
||||
static const int _statusResponseBytes =
|
||||
_statusPayloadOffset + _statusStatsSize;
|
||||
|
||||
bool _isLoading = false;
|
||||
StreamSubscription<Uint8List>? _frameSubscription;
|
||||
@@ -293,7 +294,9 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())),
|
||||
content: Text(
|
||||
context.l10n.repeater_errorLoadingStatus(e.toString()),
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
@@ -327,7 +330,10 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
Text(l10n.repeater_statusTitle),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -348,12 +354,20 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
value: 'auto',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
Icon(
|
||||
Icons.auto_mode,
|
||||
size: 20,
|
||||
color: !isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_autoUseSavedPath,
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
fontWeight: !isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -363,12 +377,20 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
value: 'flood',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
Icon(
|
||||
Icons.waves,
|
||||
size: 20,
|
||||
color: isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_forceFloodMode,
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
fontWeight: isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -379,7 +401,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.timeline),
|
||||
tooltip: l10n.repeater_pathManagement,
|
||||
onPressed: () => PathManagementDialog.show(context, contact: repeater),
|
||||
onPressed: () =>
|
||||
PathManagementDialog.show(context, contact: repeater),
|
||||
),
|
||||
IconButton(
|
||||
icon: _isLoading
|
||||
@@ -423,11 +446,17 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Theme.of(context).textTheme.headlineSmall?.color),
|
||||
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),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -453,18 +482,30 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.radio, color: Theme.of(context).textTheme.headlineSmall?.color),
|
||||
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),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
_buildInfoRow(l10n.repeater_lastRssi, _formatValue(_lastRssi, suffix: ' dB')),
|
||||
_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_noiseFloor,
|
||||
_formatValue(_noiseFloor, suffix: ' dB'),
|
||||
),
|
||||
_buildInfoRow(l10n.repeater_txAirtime, _formatDuration(_txAirSecs)),
|
||||
_buildInfoRow(l10n.repeater_rxAirtime, _formatDuration(_rxAirSecs)),
|
||||
],
|
||||
@@ -483,11 +524,17 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.analytics, color: Theme.of(context).textTheme.headlineSmall?.color),
|
||||
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),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -561,7 +608,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
if (_statusRequestedAt == null) return '—';
|
||||
final dt = _statusRequestedAt!;
|
||||
final date = '${dt.day}/${dt.month}/${dt.year}';
|
||||
final time = '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
final time =
|
||||
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
return '$date $time';
|
||||
}
|
||||
|
||||
@@ -598,7 +646,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
final direct = _formatValue(_dupDirect);
|
||||
return l10n.repeater_duplicatesFloodDirect(flood, direct);
|
||||
}
|
||||
if (_packetsRecv == null || _floodRx == null || _directRx == null) return '—';
|
||||
if (_packetsRecv == null || _floodRx == null || _directRx == null)
|
||||
return '—';
|
||||
final dupTotal = _packetsRecv! - _floodRx! - _directRx!;
|
||||
if (dupTotal < 0) return '—';
|
||||
return l10n.repeater_duplicatesTotal(dupTotal);
|
||||
|
||||
@@ -23,22 +23,21 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
|
||||
|
||||
_connectionListener = () {
|
||||
if (connector.state == MeshCoreConnectionState.disconnected) {
|
||||
_changedNavigation = false;
|
||||
} else if (connector.state == MeshCoreConnectionState.connected && !_changedNavigation) {
|
||||
} else if (connector.state == MeshCoreConnectionState.connected &&
|
||||
!_changedNavigation) {
|
||||
_changedNavigation = true;
|
||||
if (mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ContactsScreen(),
|
||||
),
|
||||
MaterialPageRoute(builder: (context) => const ContactsScreen()),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
connector.addListener(_connectionListener);
|
||||
}
|
||||
|
||||
@@ -67,9 +66,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
_buildStatusBar(context, connector),
|
||||
|
||||
// Device list
|
||||
Expanded(
|
||||
child: _buildDeviceList(context, connector),
|
||||
),
|
||||
Expanded(child: _buildDeviceList(context, connector)),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -77,8 +74,9 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
),
|
||||
floatingActionButton: Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, child) {
|
||||
final isScanning = connector.state == MeshCoreConnectionState.scanning;
|
||||
|
||||
final isScanning =
|
||||
connector.state == MeshCoreConnectionState.scanning;
|
||||
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
if (isScanning) {
|
||||
@@ -87,7 +85,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
connector.startScan();
|
||||
}
|
||||
},
|
||||
icon: isScanning
|
||||
icon: isScanning
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
@@ -97,7 +95,11 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.bluetooth_searching),
|
||||
label: Text(isScanning ? context.l10n.scanner_stop : context.l10n.scanner_scan),
|
||||
label: Text(
|
||||
isScanning
|
||||
? context.l10n.scanner_stop
|
||||
: context.l10n.scanner_scan,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -108,7 +110,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
String statusText;
|
||||
Color statusColor;
|
||||
|
||||
final l10n = context.l10n;
|
||||
final l10n = context.l10n;
|
||||
switch (connector.state) {
|
||||
case MeshCoreConnectionState.scanning:
|
||||
statusText = l10n.scanner_scanning;
|
||||
@@ -155,20 +157,13 @@ final l10n = context.l10n;
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bluetooth,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
Icon(Icons.bluetooth, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
connector.state == MeshCoreConnectionState.scanning
|
||||
? context.l10n.scanner_searchingDevices
|
||||
: context.l10n.scanner_tapToScan,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -442,7 +442,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool isGPSEnabled = customVars["gps"] == "1";
|
||||
|
||||
// Read current interval or default to 900 (15 minutes)
|
||||
final currentInterval = int.tryParse(customVars["gps_interval"] ?? "") ?? 900;
|
||||
final currentInterval =
|
||||
int.tryParse(customVars["gps_interval"] ?? "") ?? 900;
|
||||
intervalController.text = currentInterval.toString();
|
||||
|
||||
showDialog(
|
||||
@@ -782,9 +783,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
|
||||
|
||||
final maxTxPower = widget.connector.maxTxPower ?? 22;
|
||||
if (txPower == null || txPower < 0 || txPower > maxTxPower) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${l10n.settings_txPowerInvalid} (0-$maxTxPower dBm)'),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user