mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
Core Features Unread Message Tracking: Added persistent unread counts for contacts and channels with visual badges Message Deletion: Users can now long-press to delete individual messages in chats and channels SMAZ Compression: Added per-contact compression settings (previously only channels) UTF-8 Length Limiting: Text inputs now enforce protocol byte limits correctly Channel Message Paths: New screen to visualize packet routing through repeater network with map view Protocol Updates Added maxContactMessageBytes() and maxChannelMessageBytes() helpers for message length validation Changed channel PSK format from Base64 to Hexadecimal (breaking change) Added app version field to connection handshake frame UI Improvements Unread badges on all contact and channel list items Enhanced message bubbles with path visualization for channel messages Character count displays in message input fields Improved repeater CLI screen functionality New Files lib/storage/unread_store.dart - Unread tracking persistence lib/storage/contact_settings_store.dart - Per-contact SMAZ settings lib/widgets/unread_badge.dart - Unread count indicator lib/helpers/utf8_length_limiter.dart - Byte-aware text input formatter lib/screens/channel_message_path_screen.dart - Packet path visualization
484 lines
16 KiB
Dart
484 lines
16 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../connector/meshcore_connector.dart';
|
|
import '../services/app_settings_service.dart';
|
|
import '../services/notification_service.dart';
|
|
|
|
class AppSettingsScreen extends StatelessWidget {
|
|
const AppSettingsScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('App Settings'),
|
|
centerTitle: true,
|
|
),
|
|
body: Consumer2<AppSettingsService, MeshCoreConnector>(
|
|
builder: (context, settingsService, connector, child) {
|
|
return ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
_buildAppearanceCard(context, settingsService),
|
|
const SizedBox(height: 16),
|
|
_buildNotificationsCard(context, settingsService),
|
|
const SizedBox(height: 16),
|
|
_buildMessagingCard(context, settingsService),
|
|
const SizedBox(height: 16),
|
|
_buildBatteryCard(context, settingsService, connector),
|
|
const SizedBox(height: 16),
|
|
_buildMapSettingsCard(context, settingsService),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAppearanceCard(BuildContext context, AppSettingsService settingsService) {
|
|
return Card(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Padding(
|
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
child: Text(
|
|
'Appearance',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.brightness_6_outlined),
|
|
title: const Text('Theme'),
|
|
subtitle: Text(_themeModeLabel(settingsService.settings.themeMode)),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () => _showThemeModeDialog(context, settingsService),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildNotificationsCard(BuildContext context, AppSettingsService settingsService) {
|
|
return Card(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Padding(
|
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
child: Text(
|
|
'Notifications',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
SwitchListTile(
|
|
secondary: const Icon(Icons.notifications_outlined),
|
|
title: const Text('Enable Notifications'),
|
|
subtitle: const Text('Receive notifications for messages and adverts'),
|
|
value: settingsService.settings.notificationsEnabled,
|
|
onChanged: (value) async {
|
|
if (value) {
|
|
// Request permission when enabling
|
|
final granted = await NotificationService().requestPermissions();
|
|
if (!granted) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Notification permission denied'),
|
|
duration: Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
await settingsService.setNotificationsEnabled(value);
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(value
|
|
? 'Notifications enabled'
|
|
: 'Notifications disabled'),
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
const Divider(height: 1),
|
|
SwitchListTile(
|
|
secondary: Icon(
|
|
Icons.message_outlined,
|
|
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
|
),
|
|
title: Text(
|
|
'Message Notifications',
|
|
style: TextStyle(
|
|
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
|
),
|
|
),
|
|
subtitle: Text(
|
|
'Show notification when receiving new messages',
|
|
style: TextStyle(
|
|
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
|
),
|
|
),
|
|
value: settingsService.settings.notifyOnNewMessage,
|
|
onChanged: settingsService.settings.notificationsEnabled
|
|
? (value) {
|
|
settingsService.setNotifyOnNewMessage(value);
|
|
}
|
|
: null,
|
|
),
|
|
const Divider(height: 1),
|
|
SwitchListTile(
|
|
secondary: Icon(
|
|
Icons.cell_tower,
|
|
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
|
),
|
|
title: Text(
|
|
'Advertisement Notifications',
|
|
style: TextStyle(
|
|
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
|
),
|
|
),
|
|
subtitle: Text(
|
|
'Show notification when new nodes are discovered',
|
|
style: TextStyle(
|
|
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
|
),
|
|
),
|
|
value: settingsService.settings.notifyOnNewAdvert,
|
|
onChanged: settingsService.settings.notificationsEnabled
|
|
? (value) {
|
|
settingsService.setNotifyOnNewAdvert(value);
|
|
}
|
|
: null,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMessagingCard(BuildContext context, AppSettingsService settingsService) {
|
|
return Card(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Padding(
|
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
child: Text(
|
|
'Messaging',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
SwitchListTile(
|
|
secondary: const Icon(Icons.refresh_outlined),
|
|
title: const Text('Clear Path on Max Retry'),
|
|
subtitle: const Text('Reset contact path after 5 failed send attempts'),
|
|
value: settingsService.settings.clearPathOnMaxRetry,
|
|
onChanged: (value) {
|
|
settingsService.setClearPathOnMaxRetry(value);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(value
|
|
? 'Paths will be cleared after 5 failed retries'
|
|
: 'Paths will not be auto-cleared'),
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const Divider(height: 1),
|
|
SwitchListTile(
|
|
secondary: const Icon(Icons.alt_route),
|
|
title: const Text('Auto Route Rotation'),
|
|
subtitle: const Text('Cycle between best paths and flood mode'),
|
|
value: settingsService.settings.autoRouteRotationEnabled,
|
|
onChanged: (value) {
|
|
settingsService.setAutoRouteRotationEnabled(value);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(value
|
|
? 'Auto route rotation enabled'
|
|
: 'Auto route rotation disabled'),
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMapSettingsCard(BuildContext context, AppSettingsService settingsService) {
|
|
return Card(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Padding(
|
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
child: Text(
|
|
'Map Display',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
SwitchListTile(
|
|
secondary: const Icon(Icons.router_outlined),
|
|
title: const Text('Show Repeaters'),
|
|
subtitle: const Text('Display repeater nodes on the map'),
|
|
value: settingsService.settings.mapShowRepeaters,
|
|
onChanged: (value) {
|
|
settingsService.setMapShowRepeaters(value);
|
|
},
|
|
),
|
|
const Divider(height: 1),
|
|
SwitchListTile(
|
|
secondary: const Icon(Icons.chat_outlined),
|
|
title: const Text('Show Chat Nodes'),
|
|
subtitle: const Text('Display chat nodes on the map'),
|
|
value: settingsService.settings.mapShowChatNodes,
|
|
onChanged: (value) {
|
|
settingsService.setMapShowChatNodes(value);
|
|
},
|
|
),
|
|
const Divider(height: 1),
|
|
SwitchListTile(
|
|
secondary: const Icon(Icons.people_outline),
|
|
title: const Text('Show Other Nodes'),
|
|
subtitle: const Text('Display other node types on the map'),
|
|
value: settingsService.settings.mapShowOtherNodes,
|
|
onChanged: (value) {
|
|
settingsService.setMapShowOtherNodes(value);
|
|
},
|
|
),
|
|
const Divider(height: 1),
|
|
ListTile(
|
|
leading: const Icon(Icons.timer_outlined),
|
|
title: const Text('Time Filter'),
|
|
subtitle: Text(
|
|
settingsService.settings.mapTimeFilterHours == 0
|
|
? 'Show all nodes'
|
|
: 'Show nodes from last ${settingsService.settings.mapTimeFilterHours.toInt()} hours',
|
|
),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () => _showTimeFilterDialog(context, settingsService),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBatteryCard(
|
|
BuildContext context,
|
|
AppSettingsService settingsService,
|
|
MeshCoreConnector connector,
|
|
) {
|
|
final deviceId = connector.deviceId;
|
|
final isConnected = connector.isConnected && deviceId != null;
|
|
final selection =
|
|
isConnected ? settingsService.batteryChemistryForDevice(deviceId) : 'nmc';
|
|
|
|
return Card(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Padding(
|
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
child: Text(
|
|
'Battery',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.battery_full),
|
|
title: const Text('Battery Chemistry'),
|
|
subtitle: Text(
|
|
isConnected
|
|
? 'Set per device (${connector.deviceDisplayName})'
|
|
: 'Connect to a device to choose',
|
|
),
|
|
trailing: DropdownButton<String>(
|
|
value: selection,
|
|
onChanged: isConnected
|
|
? (value) {
|
|
if (value != null) {
|
|
settingsService.setBatteryChemistryForDevice(deviceId, value);
|
|
}
|
|
}
|
|
: null,
|
|
items: const [
|
|
DropdownMenuItem(
|
|
value: 'nmc',
|
|
child: Text('18650 NMC (3.0-4.2V)'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 'lifepo4',
|
|
child: Text('LiFePO4 (2.6-3.65V)'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 'lipo',
|
|
child: Text('LiPo (3.0-4.2V)'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showThemeModeDialog(BuildContext context, AppSettingsService settingsService) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Theme'),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
RadioListTile<String>(
|
|
title: const Text('System default'),
|
|
value: 'system',
|
|
groupValue: settingsService.settings.themeMode,
|
|
onChanged: (value) {
|
|
if (value != null) {
|
|
settingsService.setThemeMode(value);
|
|
Navigator.pop(context);
|
|
}
|
|
},
|
|
),
|
|
RadioListTile<String>(
|
|
title: const Text('Light'),
|
|
value: 'light',
|
|
groupValue: settingsService.settings.themeMode,
|
|
onChanged: (value) {
|
|
if (value != null) {
|
|
settingsService.setThemeMode(value);
|
|
Navigator.pop(context);
|
|
}
|
|
},
|
|
),
|
|
RadioListTile<String>(
|
|
title: const Text('Dark'),
|
|
value: 'dark',
|
|
groupValue: settingsService.settings.themeMode,
|
|
onChanged: (value) {
|
|
if (value != null) {
|
|
settingsService.setThemeMode(value);
|
|
Navigator.pop(context);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Close'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _themeModeLabel(String value) {
|
|
switch (value) {
|
|
case 'light':
|
|
return 'Light';
|
|
case 'dark':
|
|
return 'Dark';
|
|
default:
|
|
return 'System default';
|
|
}
|
|
}
|
|
|
|
void _showTimeFilterDialog(BuildContext context, AppSettingsService settingsService) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Map Time Filter'),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text('Show nodes discovered within:'),
|
|
const SizedBox(height: 16),
|
|
ListTile(
|
|
title: const Text('All time'),
|
|
leading: Radio<double>(
|
|
value: 0,
|
|
groupValue: settingsService.settings.mapTimeFilterHours,
|
|
onChanged: (value) {
|
|
if (value != null) {
|
|
settingsService.setMapTimeFilterHours(value);
|
|
Navigator.pop(context);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
ListTile(
|
|
title: const Text('Last hour'),
|
|
leading: Radio<double>(
|
|
value: 1,
|
|
groupValue: settingsService.settings.mapTimeFilterHours,
|
|
onChanged: (value) {
|
|
if (value != null) {
|
|
settingsService.setMapTimeFilterHours(value);
|
|
Navigator.pop(context);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
ListTile(
|
|
title: const Text('Last 6 hours'),
|
|
leading: Radio<double>(
|
|
value: 6,
|
|
groupValue: settingsService.settings.mapTimeFilterHours,
|
|
onChanged: (value) {
|
|
if (value != null) {
|
|
settingsService.setMapTimeFilterHours(value);
|
|
Navigator.pop(context);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
ListTile(
|
|
title: const Text('Last 24 hours'),
|
|
leading: Radio<double>(
|
|
value: 24,
|
|
groupValue: settingsService.settings.mapTimeFilterHours,
|
|
onChanged: (value) {
|
|
if (value != null) {
|
|
settingsService.setMapTimeFilterHours(value);
|
|
Navigator.pop(context);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
ListTile(
|
|
title: const Text('Last week'),
|
|
leading: Radio<double>(
|
|
value: 168,
|
|
groupValue: settingsService.settings.mapTimeFilterHours,
|
|
onChanged: (value) {
|
|
if (value != null) {
|
|
settingsService.setMapTimeFilterHours(value);
|
|
Navigator.pop(context);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Close'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|