diff --git a/lib/screens/welcome_wizard_screen.dart b/lib/screens/welcome_wizard_screen.dart index 7b82b07..56103a9 100644 --- a/lib/screens/welcome_wizard_screen.dart +++ b/lib/screens/welcome_wizard_screen.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../l10n/app_localizations.dart'; +import '../providers/connection_provider.dart'; import '../services/wizard_preferences.dart'; +import '../widgets/connection_dialog.dart'; /// Welcome wizard screen to introduce new users to the app class WelcomeWizardScreen extends StatefulWidget { @@ -14,12 +17,171 @@ class WelcomeWizardScreen extends StatefulWidget { class _WelcomeWizardScreenState extends State { final PageController _pageController = PageController(); + final TextEditingController _deviceNameController = TextEditingController(); int _currentPage = 0; - static const int _totalPages = 5; + bool _isApplyingDeviceSetup = false; + static const int _totalPages = 6; + static const List<_RadioPreset> _radioPresets = [ + // Official MeshCore config presets from https://api.meshcore.nz/api/v1/config + _RadioPreset( + id: 'australia', + label: 'Australia', + summary: '915.800 MHz, BW 250 kHz, SF10, CR5', + frequencyKhz: 915800, + bandwidth: 8, + spreadingFactor: 10, + codingRate: 5, + ), + _RadioPreset( + id: 'australia_narrow', + label: 'Australia (Narrow)', + summary: '916.575 MHz, BW 62.5 kHz, SF7, CR8', + frequencyKhz: 916575, + bandwidth: 6, + spreadingFactor: 7, + codingRate: 8, + ), + _RadioPreset( + id: 'australia_sa_wa', + label: 'Australia: SA, WA', + summary: '923.125 MHz, BW 62.5 kHz, SF8, CR8', + frequencyKhz: 923125, + bandwidth: 6, + spreadingFactor: 8, + codingRate: 8, + ), + _RadioPreset( + id: 'australia_qld', + label: 'Australia: QLD', + summary: '923.125 MHz, BW 62.5 kHz, SF8, CR5', + frequencyKhz: 923125, + bandwidth: 6, + spreadingFactor: 8, + codingRate: 5, + ), + _RadioPreset( + id: 'eu_uk_narrow', + label: 'EU/UK (Narrow)', + summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8', + frequencyKhz: 869618, + bandwidth: 6, + spreadingFactor: 8, + codingRate: 8, + ), + _RadioPreset( + id: 'eu_uk_deprecated', + label: 'EU/UK (Deprecated)', + summary: '869.525 MHz, BW 250 kHz, SF11, CR5', + frequencyKhz: 869525, + bandwidth: 8, + spreadingFactor: 11, + codingRate: 5, + ), + _RadioPreset( + id: 'czech_republic_narrow', + label: 'Czech Republic (Narrow)', + summary: '869.432 MHz, BW 62.5 kHz, SF7, CR5', + frequencyKhz: 869432, + bandwidth: 6, + spreadingFactor: 7, + codingRate: 5, + ), + _RadioPreset( + id: 'eu_433_long_range', + label: 'EU 433MHz (Long Range)', + summary: '433.650 MHz, BW 250 kHz, SF11, CR5', + frequencyKhz: 433650, + bandwidth: 8, + spreadingFactor: 11, + codingRate: 5, + ), + _RadioPreset( + id: 'new_zealand', + label: 'New Zealand', + summary: '917.375 MHz, BW 250 kHz, SF11, CR5', + frequencyKhz: 917375, + bandwidth: 8, + spreadingFactor: 11, + codingRate: 5, + ), + _RadioPreset( + id: 'new_zealand_narrow', + label: 'New Zealand (Narrow)', + summary: '917.375 MHz, BW 62.5 kHz, SF7, CR5', + frequencyKhz: 917375, + bandwidth: 6, + spreadingFactor: 7, + codingRate: 5, + ), + _RadioPreset( + id: 'portugal_433', + label: 'Portugal 433', + summary: '433.375 MHz, BW 62.5 kHz, SF9, CR6', + frequencyKhz: 433375, + bandwidth: 6, + spreadingFactor: 9, + codingRate: 6, + ), + _RadioPreset( + id: 'portugal_868', + label: 'Portugal 868', + summary: '869.618 MHz, BW 62.5 kHz, SF7, CR6', + frequencyKhz: 869618, + bandwidth: 6, + spreadingFactor: 7, + codingRate: 6, + ), + _RadioPreset( + id: 'switzerland', + label: 'Switzerland', + summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8', + frequencyKhz: 869618, + bandwidth: 6, + spreadingFactor: 8, + codingRate: 8, + ), + _RadioPreset( + id: 'usa_canada_recommended', + label: 'USA/Canada (Recommended)', + summary: '910.525 MHz, BW 62.5 kHz, SF7, CR5', + frequencyKhz: 910525, + bandwidth: 6, + spreadingFactor: 7, + codingRate: 5, + ), + _RadioPreset( + id: 'vietnam_narrow', + label: 'Vietnam (Narrow)', + summary: '920.250 MHz, BW 62.5 kHz, SF8, CR5', + frequencyKhz: 920250, + bandwidth: 6, + spreadingFactor: 8, + codingRate: 5, + ), + _RadioPreset( + id: 'vietnam_deprecated', + label: 'Vietnam (Deprecated)', + summary: '920.250 MHz, BW 250 kHz, SF11, CR5', + frequencyKhz: 920250, + bandwidth: 8, + spreadingFactor: 11, + codingRate: 5, + ), + ]; + _RadioPreset _selectedPreset = _radioPresets[4]; + + @override + void initState() { + super.initState(); + final deviceInfo = context.read().deviceInfo; + _deviceNameController.text = + deviceInfo.selfName ?? deviceInfo.deviceName ?? ''; + } @override void dispose() { _pageController.dispose(); + _deviceNameController.dispose(); super.dispose(); } @@ -30,7 +192,9 @@ class _WelcomeWizardScreenState extends State { } void _nextPage() { - if (_currentPage < _totalPages - 1) { + if (_currentPage == 1) { + _handleDeviceSetupAction(); + } else if (_currentPage < _totalPages - 1) { _pageController.nextPage( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, @@ -56,6 +220,96 @@ class _WelcomeWizardScreenState extends State { } } + Future _openConnectionDialog() async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => const ConnectionDialog(), + ); + if (!mounted) return; + final connectionProvider = context.read(); + if (connectionProvider.deviceInfo.isConnected) { + await connectionProvider.refreshDeviceInfo(); + } + if (!mounted) return; + final deviceInfo = connectionProvider.deviceInfo; + final fetchedName = deviceInfo.selfName ?? deviceInfo.deviceName ?? ''; + if (fetchedName.isNotEmpty) { + setState(() { + _deviceNameController.text = fetchedName; + }); + } else { + setState(() {}); + } + } + + Future _handleDeviceSetupAction() async { + final l10n = AppLocalizations.of(context)!; + final connectionProvider = context.read(); + final deviceInfo = connectionProvider.deviceInfo; + if (!deviceInfo.isConnected) { + await _openConnectionDialog(); + return; + } + + final trimmedName = _deviceNameController.text.trim(); + if (trimmedName.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enter a device name before continuing.')), + ); + return; + } + + setState(() { + _isApplyingDeviceSetup = true; + }); + + try { + await connectionProvider.setAdvertName(trimmedName); + await connectionProvider.setRadioParams( + frequency: _selectedPreset.frequencyKhz, + bandwidth: _selectedPreset.bandwidth, + spreadingFactor: _selectedPreset.spreadingFactor, + codingRate: _selectedPreset.codingRate, + ); + await connectionProvider.refreshDeviceInfo(); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Saved $trimmedName with ${_selectedPreset.label}.'), + backgroundColor: Colors.green, + ), + ); + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.failedToSave(e.toString())), + backgroundColor: Colors.red, + ), + ); + } finally { + if (mounted) { + setState(() { + _isApplyingDeviceSetup = false; + }); + } + } + } + + void _skipDeviceSetupStep() { + if (_isApplyingDeviceSetup) return; + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -68,7 +322,7 @@ class _WelcomeWizardScreenState extends State { children: [ // Top bar with skip button Padding( - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -96,8 +350,10 @@ class _WelcomeWizardScreenState extends State { child: PageView( controller: _pageController, onPageChanged: _onPageChanged, + physics: const NeverScrollableScrollPhysics(), children: [ _buildWelcomePage(context, l10n, colorScheme), + _buildConnectDevicePage(context, l10n, colorScheme), _buildConnectingPage(context, l10n, colorScheme), _buildChannelPage(context, l10n, colorScheme), _buildContactsPage(context, l10n, colorScheme), @@ -108,7 +364,7 @@ class _WelcomeWizardScreenState extends State { // Page indicators Padding( - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: List.generate( @@ -130,11 +386,11 @@ class _WelcomeWizardScreenState extends State { // Next/Get Started button Padding( - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: SizedBox( width: double.infinity, child: ElevatedButton( - onPressed: _nextPage, + onPressed: _isApplyingDeviceSetup ? null : _nextPage, style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16.0), shape: RoundedRectangleBorder( @@ -142,7 +398,9 @@ class _WelcomeWizardScreenState extends State { ), ), child: Text( - _currentPage < _totalPages - 1 + _currentPage == 1 + ? _deviceSetupButtonLabel(context) + : _currentPage < _totalPages - 1 ? l10n.wizardNext : l10n.wizardGetStarted, style: const TextStyle(fontSize: 16), @@ -164,8 +422,26 @@ class _WelcomeWizardScreenState extends State { return _buildPage( icon: Icons.waving_hand, iconColor: Colors.orange, - title: l10n.wizardWelcomeTitle, - description: l10n.wizardWelcomeDescription, + title: 'Welcome to MeshCore SAR', + description: + 'This app combines MeshCore messaging, SAR field updates, mapping, and device tools in one place.', + features: [ + _FeatureItem( + icon: Icons.chat_bubble_outline, + text: + 'Send direct, room, and channel messages from the main Messages tab.', + ), + _FeatureItem( + icon: Icons.emergency_share, + text: + 'Share SAR markers, map drawings, voice clips, and images over the mesh.', + ), + _FeatureItem( + icon: Icons.settings_input_antenna, + text: + 'Connect over BLE or TCP, then manage the companion radio from inside the app.', + ), + ], colorScheme: colorScheme, ); } @@ -176,33 +452,247 @@ class _WelcomeWizardScreenState extends State { ColorScheme colorScheme, ) { return _buildPage( - icon: Icons.bluetooth_searching, + icon: Icons.forum_rounded, iconColor: Colors.blue, - title: l10n.wizardConnectingTitle, - description: l10n.wizardConnectingDescription, + title: 'Messaging and Field Reports', + description: + 'Messages are more than plain text here. The app already supports several operational payloads and transfer workflows.', features: [ - _FeatureItem(icon: Icons.radio, text: l10n.wizardConnectingFeature1), - _FeatureItem(icon: Icons.link, text: l10n.wizardConnectingFeature2), - _FeatureItem(icon: Icons.wifi_off, text: l10n.wizardConnectingFeature3), + _FeatureItem( + icon: Icons.chat, + text: + 'Send direct messages, room posts, and channel traffic from one composer.', + ), + _FeatureItem( + icon: Icons.campaign, + text: + 'Create SAR updates and reusable SAR templates for common field reports.', + ), + _FeatureItem( + icon: Icons.mic, + text: + 'Transfer voice sessions and images, with progress and airtime estimates in the UI.', + ), ], colorScheme: colorScheme, ); } + Widget _buildConnectDevicePage( + BuildContext context, + AppLocalizations l10n, + ColorScheme colorScheme, + ) { + final connectionProvider = context.watch(); + final deviceInfo = connectionProvider.deviceInfo; + final isConnected = deviceInfo.isConnected; + final connectedName = deviceInfo.selfName ?? deviceInfo.deviceName; + + return LayoutBuilder( + builder: (context, constraints) => Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 600, + maxHeight: constraints.maxHeight, + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildPageHeader( + context: context, + colorScheme: colorScheme, + icon: Icons.bluetooth_connected, + iconColor: Colors.green, + title: 'Connect device', + description: + 'Connect your MeshCore radio, choose a name, and apply a radio preset before continuing.', + badge: 'Setup', + ), + const SizedBox(height: 16), + _buildContentCard( + colorScheme: colorScheme, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + isConnected + ? Icons.check_circle + : Icons.bluetooth_searching, + color: isConnected + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + isConnected + ? 'Connected to ${connectedName ?? "device"}' + : 'No device connected yet', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + TextButton( + onPressed: _openConnectionDialog, + child: Text(isConnected ? 'Change' : l10n.connect), + ), + ], + ), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: _skipDeviceSetupStep, + child: const Text('Skip for now'), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + _buildContentCard( + colorScheme: colorScheme, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _deviceNameController, + textInputAction: TextInputAction.done, + decoration: const InputDecoration( + labelText: 'Device name', + border: OutlineInputBorder(), + helperText: + 'This name is advertised to other MeshCore users.', + ), + ), + const SizedBox(height: 12), + DropdownButtonFormField<_RadioPreset>( + initialValue: _selectedPreset, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Config region', + border: OutlineInputBorder(), + helperText: + 'Uses the full official MeshCore preset list. Default is EU/UK (Narrow).', + ), + items: _radioPresets + .map( + (preset) => DropdownMenuItem<_RadioPreset>( + value: preset, + child: Text( + preset.label, + overflow: TextOverflow.ellipsis, + ), + ), + ) + .toList(), + onChanged: (preset) { + if (preset == null) return; + setState(() { + _selectedPreset = preset; + }); + }, + ), + ], + ), + ), + const SizedBox(height: 16), + _buildContentCard( + colorScheme: colorScheme, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _selectedPreset.label, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + Text( + _selectedPreset.summary, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 14), + ...[ + _FeatureItem( + icon: Icons.rule, + text: + 'Make sure the selected preset matches your local radio regulations.', + ), + _FeatureItem( + icon: Icons.settings_input_antenna, + text: + 'The list matches the official MeshCore config tool preset feed.', + ), + _FeatureItem( + icon: Icons.public, + text: + 'EU/UK (Narrow) stays selected by default for onboarding.', + ), + ].map( + (feature) => Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: _buildFeatureRow( + context, + colorScheme, + feature, + iconSize: 20, + gap: 12, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + String _deviceSetupButtonLabel(BuildContext context) { + final isConnected = context + .watch() + .deviceInfo + .isConnected; + if (_isApplyingDeviceSetup) { + return 'Saving...'; + } + return isConnected ? 'Save and continue' : 'Connect device'; + } + Widget _buildChannelPage( BuildContext context, AppLocalizations l10n, ColorScheme colorScheme, ) { return _buildPage( - icon: Icons.campaign, + icon: Icons.groups_rounded, iconColor: Colors.purple, - title: l10n.wizardChannelTitle, - description: l10n.wizardChannelDescription, + title: 'Contacts, Rooms, and Repeaters', + description: + 'The Contacts tab organizes the network you discover and the routes you learn over time.', features: [ - _FeatureItem(icon: Icons.public, text: l10n.wizardChannelFeature1), - _FeatureItem(icon: Icons.groups, text: l10n.wizardChannelFeature2), - _FeatureItem(icon: Icons.send, text: l10n.wizardChannelFeature3), + _FeatureItem( + icon: Icons.person_add_alt_1, + text: + 'Review team members, repeaters, rooms, channels, and pending adverts in one list.', + ), + _FeatureItem( + icon: Icons.route, + text: + 'Use smart ping, room login, learned paths, and route reset tools when connectivity gets messy.', + ), + _FeatureItem( + icon: Icons.hub, + text: + 'Create channels and manage network destinations without leaving the app.', + ), ], colorScheme: colorScheme, ); @@ -214,16 +704,26 @@ class _WelcomeWizardScreenState extends State { ColorScheme colorScheme, ) { return _buildPage( - icon: Icons.people, + icon: Icons.map_rounded, iconColor: Colors.teal, - title: l10n.wizardContactsTitle, - description: l10n.wizardContactsDescription, + title: 'Map, Trails, and Shared Geometry', + description: + 'The app map is tied directly into messaging, tracking, and SAR overlays instead of being a separate viewer.', features: [ - _FeatureItem(icon: Icons.person_add, text: l10n.wizardContactsFeature1), - _FeatureItem(icon: Icons.chat, text: l10n.wizardContactsFeature2), _FeatureItem( - icon: Icons.battery_std, - text: l10n.wizardContactsFeature3, + icon: Icons.location_on, + text: + 'Track your own position, teammate locations, and movement trails on the map.', + ), + _FeatureItem( + icon: Icons.draw, + text: + 'Open drawings from messages, preview them inline, and remove them from the map when needed.', + ), + _FeatureItem( + icon: Icons.router, + text: + 'Use repeater map views and shared overlays to understand network reach in the field.', ), ], colorScheme: colorScheme, @@ -236,18 +736,27 @@ class _WelcomeWizardScreenState extends State { ColorScheme colorScheme, ) { return _buildPage( - icon: Icons.map, + icon: Icons.tune_rounded, iconColor: Colors.red, - title: l10n.wizardMapTitle, - description: l10n.wizardMapDescription, + title: 'Tools Beyond Messaging', + description: + 'There is more here than the four main tabs. The app also includes configuration, diagnostics, and optional sensor workflows.', features: [ - _FeatureItem(icon: Icons.location_on, text: l10n.wizardMapFeature1), _FeatureItem( - icon: Icons.person_pin_circle, - text: l10n.wizardMapFeature2, + icon: Icons.settings, + text: + 'Open device config to change radio settings, telemetry, TX power, and companion details.', + ), + _FeatureItem( + icon: Icons.sensors, + text: + 'Enable the Sensors tab when you want watched sensor dashboards and quick refresh actions.', + ), + _FeatureItem( + icon: Icons.analytics_outlined, + text: + 'Use packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.', ), - _FeatureItem(icon: Icons.offline_pin, text: l10n.wizardMapFeature3), - _FeatureItem(icon: Icons.draw, text: l10n.wizardMapFeature4), ], colorScheme: colorScheme, ); @@ -261,69 +770,148 @@ class _WelcomeWizardScreenState extends State { List<_FeatureItem>? features, required ColorScheme colorScheme, }) { - return SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(height: 20), - // Icon - Container( - padding: const EdgeInsets.all(24.0), - decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: Icon(icon, size: 80, color: iconColor), + return LayoutBuilder( + builder: (context, constraints) => Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 560, + maxHeight: constraints.maxHeight, ), - const SizedBox(height: 32), - // Title - Text( - title, - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - // Description - Text( - description, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurface.withValues(alpha: 0.7), - height: 1.5, - ), - textAlign: TextAlign.center, - ), - if (features != null && features.isNotEmpty) ...[ - const SizedBox(height: 32), - // Features list - ...features.map( - (feature) => Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Row( - children: [ - Icon(feature.icon, color: colorScheme.primary, size: 24), - const SizedBox(width: 16), - Expanded( - child: Text( - feature.text, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurface, - ), - ), - ), - ], + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildPageHeader( + context: context, + colorScheme: colorScheme, + icon: icon, + iconColor: iconColor, + title: title, + description: description, + badge: 'Overview', ), - ), + if (features != null && features.isNotEmpty) ...[ + const SizedBox(height: 18), + _buildContentCard( + colorScheme: colorScheme, + child: Column( + children: features + .map( + (feature) => Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: _buildFeatureRow( + context, + colorScheme, + feature, + ), + ), + ) + .toList(), + ), + ), + ], + ], ), - ], - const SizedBox(height: 20), - ], + ), + ), ), ); } + + Widget _buildPageHeader({ + required BuildContext context, + required ColorScheme colorScheme, + required IconData icon, + required Color iconColor, + required String title, + required String description, + required String badge, + }) { + return Column( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + badge, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 56, color: iconColor), + ), + const SizedBox(height: 18), + Text( + title, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + Text( + description, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.72), + height: 1.4, + ), + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildContentCard({ + required ColorScheme colorScheme, + required Widget child, + }) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(18), + ), + child: child, + ); + } + + Widget _buildFeatureRow( + BuildContext context, + ColorScheme colorScheme, + _FeatureItem feature, { + double iconSize = 22, + double gap = 14, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(feature.icon, color: colorScheme.primary, size: iconSize), + SizedBox(width: gap), + Expanded( + child: Text( + feature.text, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface), + ), + ), + ], + ); + } } class _FeatureItem { @@ -332,3 +920,23 @@ class _FeatureItem { _FeatureItem({required this.icon, required this.text}); } + +class _RadioPreset { + final String id; + final String label; + final String summary; + final int frequencyKhz; + final int bandwidth; + final int spreadingFactor; + final int codingRate; + + const _RadioPreset({ + required this.id, + required this.label, + required this.summary, + required this.frequencyKhz, + required this.bandwidth, + required this.spreadingFactor, + required this.codingRate, + }); +} diff --git a/lib/widgets/contacts/add_channel_dialog.dart b/lib/widgets/contacts/add_channel_dialog.dart index 94c19e0..d7c1145 100644 --- a/lib/widgets/contacts/add_channel_dialog.dart +++ b/lib/widgets/contacts/add_channel_dialog.dart @@ -35,16 +35,17 @@ class _AddChannelDialogState extends State { /// Validate channel name String? _validateName(String? value) { final l10n = AppLocalizations.of(context)!; + final trimmedValue = value?.trim() ?? ''; - if (value == null || value.trim().isEmpty) { + if (trimmedValue.isEmpty) { return l10n.channelNameRequired; } - if (value.length > 31) { + if (trimmedValue.length > 31) { return l10n.channelNameTooLong; } - if (!_isAscii(value)) { + if (!_isAscii(trimmedValue)) { return l10n.invalidAsciiCharacters; } @@ -105,7 +106,8 @@ class _AddChannelDialogState extends State { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final theme = Theme.of(context); - final isHashChannel = _nameController.text.startsWith('#'); + final normalizedName = _nameController.text.trimLeft(); + final isHashChannel = normalizedName.startsWith('#'); return AlertDialog( title: Text(l10n.addChannel), @@ -163,8 +165,15 @@ class _AddChannelDialogState extends State { enabled: !_isCreating, maxLength: 31, validator: _validateName, - textInputAction: TextInputAction.next, + textInputAction: isHashChannel + ? TextInputAction.done + : TextInputAction.next, onChanged: (_) => setState(() {}), // Rebuild to update icon + onFieldSubmitted: (_) { + if (isHashChannel) { + _handleCreate(); + } + }, ), // Channel Secret Field (only show for private channels) if (!isHashChannel) ...[ diff --git a/test/widgets/add_channel_dialog_test.dart b/test/widgets/add_channel_dialog_test.dart new file mode 100644 index 0000000..078add8 --- /dev/null +++ b/test/widgets/add_channel_dialog_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/widgets/contacts/add_channel_dialog.dart'; + +void main() { + Future pumpDialog( + WidgetTester tester, { + required Future Function(String name, String secret) onCreateChannel, + }) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: AddChannelDialog(onCreateChannel: onCreateChannel), + ), + ), + ), + ), + ); + } + + testWidgets('submits hash channels with an empty secret', (tester) async { + String? submittedName; + String? submittedSecret; + + await pumpDialog( + tester, + onCreateChannel: (name, secret) async { + submittedName = name; + submittedSecret = secret; + }, + ); + + await tester.enterText(find.byType(TextFormField).first, '#slovenia'); + await tester.pump(); + await tester.tap(find.text('Create Channel')); + await tester.pumpAndSettle(); + + expect(submittedName, '#slovenia'); + expect(submittedSecret, ''); + }); + + testWidgets('uses done action for hash channels', (tester) async { + String? submittedName; + + await pumpDialog( + tester, + onCreateChannel: (name, secret) async { + submittedName = name; + }, + ); + + await tester.enterText(find.byType(TextFormField).first, '#slovenia'); + await tester.pump(); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(submittedName, '#slovenia'); + }); +}