From c84cc55b672c01314004f7b6b14712507d32d142 Mon Sep 17 00:00:00 2001 From: MasuRii Date: Sat, 20 Dec 2025 00:22:07 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20Add=20location=20suggestions,?= =?UTF-8?q?=20loading=20indicators,=20and=20transport=20mode=20UX=20improv?= =?UTF-8?q?ements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ“ Summary: This commit addresses three user-reported issues in the PH Fare Calculator app: 1. Map picker search field now shows location suggestions 2. All location input fields display loading indicators during search 3. Transport modes default to disabled with new selection modal and quick toggle πŸ”§ Changes: πŸ—ΊοΈ Map Picker Location Suggestions: - Replaced placeholder TextField with Autocomplete widget - Integrated GeocodingService for location search - Added selection logic to move map camera to chosen location ⏳ Loading Indicators: - Added ValueNotifier pattern for tracking search state - Display 16px CircularProgressIndicator in input suffix while fetching - Applied to both main screen (origin/destination) and map picker search 🚌 Transport Mode Improvements: - Changed default for all transport modes to disabled for new users - Added hasSetTransportModePreferences() to distinguish new vs existing users - Created TransportModeSelectionModal with Material 3 bottom sheet design - Modal shows modes grouped by category (Road, Rail, Water) - Added quick-access "Modes" button in TravelOptionsBar with badge count - Modal appears when calculating fare with no modes enabled πŸ§ͺ Tests: - Updated MockSettingsService and FakeSettingsService with new interface - Fixed flaky test finder in main_screen_test.dart - All 289 tests pass --- lib/src/presentation/screens/main_screen.dart | 133 ++++- .../screens/map_picker_screen.dart | 220 +++++-- .../presentation/screens/settings_screen.dart | 12 +- .../main_screen/location_input_section.dart | 171 ++++-- .../transport_mode_selection_modal.dart | 535 ++++++++++++++++++ .../main_screen/travel_options_bar.dart | 58 +- lib/src/services/settings_service.dart | 24 +- test/helpers/mocks.dart | 7 + test/screens/main_screen_test.dart | 18 +- .../screens/onboarding_localization_test.dart | 6 + 10 files changed, 1065 insertions(+), 119 deletions(-) create mode 100644 lib/src/presentation/widgets/main_screen/transport_mode_selection_modal.dart diff --git a/lib/src/presentation/screens/main_screen.dart b/lib/src/presentation/screens/main_screen.dart index d5bd5b3..2d66dc3 100644 --- a/lib/src/presentation/screens/main_screen.dart +++ b/lib/src/presentation/screens/main_screen.dart @@ -6,8 +6,11 @@ import 'package:latlong2/latlong.dart'; import '../../core/di/injection.dart'; import '../../l10n/app_localizations.dart'; import '../../models/connectivity_status.dart'; +import '../../models/fare_formula.dart'; +import '../../repositories/fare_repository.dart'; import '../../services/connectivity/connectivity_service.dart'; import '../../services/fare_comparison_service.dart'; +import '../../services/settings_service.dart'; import '../controllers/main_screen_controller.dart'; import '../widgets/main_screen/calculate_fare_button.dart'; import '../widgets/main_screen/error_message_banner.dart'; @@ -19,6 +22,7 @@ import '../widgets/main_screen/main_screen_app_bar.dart'; import '../widgets/main_screen/map_preview.dart'; import '../widgets/main_screen/offline_status_banner.dart'; import '../widgets/main_screen/passenger_bottom_sheet.dart'; +import '../widgets/main_screen/transport_mode_selection_modal.dart'; import '../widgets/main_screen/travel_options_bar.dart'; import 'map_picker_screen.dart'; @@ -34,20 +38,34 @@ class MainScreen extends StatefulWidget { class _MainScreenState extends State { late final MainScreenController _controller; late final ConnectivityService _connectivityService; + late final SettingsService _settingsService; + late final FareRepository _fareRepository; final TextEditingController _originTextController = TextEditingController(); final TextEditingController _destinationTextController = TextEditingController(); StreamSubscription? _connectivitySubscription; ConnectivityStatus _connectivityStatus = ConnectivityStatus.online; + /// Number of enabled transport modes (not hidden). + int _enabledModesCount = 0; + + /// Total number of available transport modes. + int _totalModesCount = 0; + + /// Cached list of all formulas for modal display. + List _allFormulas = []; + @override void initState() { super.initState(); _controller = MainScreenController(); _connectivityService = getIt(); + _settingsService = getIt(); + _fareRepository = getIt(); _controller.addListener(_onControllerChanged); _initializeData(); _initConnectivity(); + _loadTransportModeCounts(); } Future _initConnectivity() async { @@ -61,6 +79,35 @@ class _MainScreenState extends State { }); } + /// Loads transport mode counts for the quick-access button badge. + Future _loadTransportModeCounts() async { + try { + final allFormulas = await _fareRepository.getAllFormulas(); + final hiddenModes = await _settingsService.getHiddenTransportModes(); + + // Count unique mode-subtype combinations + final allModeKeys = {}; + for (final formula in allFormulas) { + allModeKeys.add('${formula.mode}::${formula.subType}'); + } + + final enabledCount = allModeKeys + .where((key) => !hiddenModes.contains(key)) + .length; + + if (mounted) { + setState(() { + _allFormulas = allFormulas; + _totalModesCount = allModeKeys.length; + _enabledModesCount = enabledCount; + }); + } + } catch (e) { + // Silently handle error - the button will show 0/0 + debugPrint('Failed to load transport mode counts: $e'); + } + } + @override void dispose() { _connectivitySubscription?.cancel(); @@ -152,6 +199,9 @@ class _MainScreenState extends State { sortCriteria: _controller.sortCriteria, onPassengerTap: _showPassengerBottomSheet, onSortChanged: _controller.setSortCriteria, + enabledModesCount: _enabledModesCount, + totalModesCount: _totalModesCount, + onTransportModesTap: _handleTransportModesTap, ), const SizedBox(height: 16), // Map height: 280 when no fare results (40% larger), 200 when showing results @@ -169,7 +219,7 @@ class _MainScreenState extends State { CalculateFareButton( canCalculate: _controller.canCalculate, isCalculating: _controller.isCalculating, - onPressed: _controller.calculateFare, + onPressed: _handleCalculateFare, ), if (_controller.errorMessage != null) ...[ const SizedBox(height: 16), @@ -292,4 +342,85 @@ class _MainScreenState extends State { ); } } + + /// Handles the transport modes quick-access button tap. + /// Shows the transport mode selection modal and updates the count after. + Future _handleTransportModesTap() async { + // Ensure we have formulas loaded + if (_allFormulas.isEmpty) { + await _loadTransportModeCounts(); + } + + if (!mounted || _allFormulas.isEmpty) return; + + await TransportModeSelectionModal.show( + context: context, + settingsService: _settingsService, + availableFormulas: _allFormulas, + ); + + // Refresh the count after modal closes + await _loadTransportModeCounts(); + } + + /// Handles fare calculation with transport mode check. + /// If no transport modes are enabled, shows the mode selection modal first. + Future _handleCalculateFare() async { + // Check if user has set transport mode preferences + final hasSetPreferences = await _settingsService + .hasSetTransportModePreferences(); + + if (!hasSetPreferences) { + // User hasn't set any preferences - show the modal + final allFormulas = await _fareRepository.getAllFormulas(); + + if (!mounted) return; + + final confirmed = await TransportModeSelectionModal.show( + context: context, + settingsService: _settingsService, + availableFormulas: allFormulas, + ); + + // Refresh the count after modal closes + await _loadTransportModeCounts(); + + if (!confirmed) { + // User cancelled - don't proceed with fare calculation + return; + } + } else { + // User has set preferences, check if any modes are actually enabled + final hiddenModes = await _settingsService.getHiddenTransportModes(); + final allFormulas = await _fareRepository.getAllFormulas(); + + // Check if ALL modes are hidden + final allModesHidden = allFormulas.every((formula) { + final modeSubTypeKey = '${formula.mode}::${formula.subType}'; + return hiddenModes.contains(modeSubTypeKey); + }); + + if (allModesHidden) { + // All modes are hidden - show the modal + if (!mounted) return; + + final confirmed = await TransportModeSelectionModal.show( + context: context, + settingsService: _settingsService, + availableFormulas: allFormulas, + ); + + // Refresh the count after modal closes + await _loadTransportModeCounts(); + + if (!confirmed) { + // User cancelled - don't proceed with fare calculation + return; + } + } + } + + // Proceed with fare calculation + await _controller.calculateFare(); + } } diff --git a/lib/src/presentation/screens/map_picker_screen.dart b/lib/src/presentation/screens/map_picker_screen.dart index 0964965..928a541 100644 --- a/lib/src/presentation/screens/map_picker_screen.dart +++ b/lib/src/presentation/screens/map_picker_screen.dart @@ -3,6 +3,9 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import '../../core/di/injection.dart'; +import '../../core/errors/failures.dart'; +import '../../models/location.dart'; +import '../../services/geocoding/geocoding_service.dart'; import '../../services/offline/offline_map_service.dart'; import '../widgets/offline_indicator.dart'; @@ -29,11 +32,11 @@ class MapPickerScreen extends StatefulWidget { class _MapPickerScreenState extends State with SingleTickerProviderStateMixin { late final MapController _mapController; + late final GeocodingService _geocodingService; LatLng? _selectedLocation; bool _isMapMoving = false; + final ValueNotifier _isSearchingLocation = ValueNotifier(false); String _addressText = 'Move map to select location'; - final TextEditingController _searchController = TextEditingController(); - bool _isSearching = false; // Animation controller for pin bounce effect late final AnimationController _pinAnimationController; @@ -47,6 +50,7 @@ class _MapPickerScreenState extends State void initState() { super.initState(); _mapController = MapController(); + _geocodingService = getIt(); _selectedLocation = widget.initialLocation ?? _defaultCenter; // Initialize pin animation controller @@ -80,7 +84,7 @@ class _MapPickerScreenState extends State @override void dispose() { _pinAnimationController.dispose(); - _searchController.dispose(); + _isSearchingLocation.dispose(); super.dispose(); } @@ -128,11 +132,36 @@ class _MapPickerScreenState extends State _updateAddress(_defaultCenter); } - void _onSearchSubmitted(String query) { - // In a real app, this would search for the location - // For now, we just close the search + Future> _searchLocations(String query) async { + if (query.trim().isEmpty) { + _isSearchingLocation.value = false; + return []; + } + + // Set loading state before fetching + _isSearchingLocation.value = true; + + try { + final results = await _geocodingService.getLocations(query); + return results; + } on Failure { + // Handle failures gracefully - return empty list + return []; + } catch (_) { + // Handle unexpected errors gracefully + return []; + } finally { + // Clear loading state after fetching + _isSearchingLocation.value = false; + } + } + + void _onLocationSelected(Location location) { + final newLatLng = LatLng(location.latitude, location.longitude); + _mapController.move(newLatLng, 15.0); setState(() { - _isSearching = false; + _selectedLocation = newLatLng; + _addressText = location.name; }); FocusScope.of(context).unfocus(); } @@ -368,54 +397,139 @@ class _MapPickerScreenState extends State child: Semantics( label: 'Search for a location', textField: true, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), - child: TextField( - controller: _searchController, - onTap: () => setState(() => _isSearching = true), - onSubmitted: _onSearchSubmitted, - decoration: InputDecoration( - hintText: 'Search location...', - hintStyle: theme.textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6), - ), - prefixIcon: Icon( - Icons.search_rounded, - color: colorScheme.primary, - ), - suffixIcon: _isSearching || _searchController.text.isNotEmpty - ? IconButton( - icon: Icon( - Icons.close_rounded, - color: colorScheme.onSurfaceVariant, - ), - onPressed: () { - _searchController.clear(); - setState(() => _isSearching = false); - FocusScope.of(context).unfocus(); + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + LayoutBuilder( + builder: (context, constraints) { + return Autocomplete( + displayStringForOption: (Location option) => option.name, + optionsBuilder: (TextEditingValue textEditingValue) async { + if (textEditingValue.text.trim().isEmpty) { + return const Iterable.empty(); + } + return _searchLocations(textEditingValue.text); + }, + onSelected: _onLocationSelected, + fieldViewBuilder: + ( + BuildContext context, + TextEditingController textEditingController, + FocusNode focusNode, + VoidCallback onFieldSubmitted, + ) { + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: ValueListenableBuilder( + valueListenable: _isSearchingLocation, + builder: (context, isSearching, child) { + return TextField( + controller: textEditingController, + focusNode: focusNode, + decoration: InputDecoration( + hintText: 'Search location...', + hintStyle: theme.textTheme.bodyLarge + ?.copyWith( + color: colorScheme.onSurfaceVariant + .withValues(alpha: 0.6), + ), + prefixIcon: Icon( + Icons.search_rounded, + color: colorScheme.primary, + ), + suffixIcon: isSearching + ? Padding( + padding: const EdgeInsets.all(12), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ), + ) + : (focusNode.hasFocus || + textEditingController + .text + .isNotEmpty) + ? IconButton( + icon: Icon( + Icons.close_rounded, + color: colorScheme.onSurfaceVariant, + ), + onPressed: () { + textEditingController.clear(); + FocusScope.of(context).unfocus(); + }, + ) + : null, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), + ), + ); + }, + ), + ); }, - ) - : null, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 16, - ), + optionsViewBuilder: (context, onSelected, options) { + return Align( + alignment: Alignment.topLeft, + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(12), + child: Container( + width: constraints.maxWidth, + constraints: const BoxConstraints(maxHeight: 200), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(12), + ), + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (BuildContext context, int index) { + final Location option = options.elementAt(index); + return ListTile( + leading: Icon( + Icons.location_on_outlined, + color: colorScheme.onSurfaceVariant, + ), + title: Text( + option.name, + style: theme.textTheme.bodyMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + onTap: () => onSelected(option), + ); + }, + ), + ), + ), + ); + }, + ); + }, ), - ), + ], ), ), ); diff --git a/lib/src/presentation/screens/settings_screen.dart b/lib/src/presentation/screens/settings_screen.dart index f7d1ac9..77fb1bb 100644 --- a/lib/src/presentation/screens/settings_screen.dart +++ b/lib/src/presentation/screens/settings_screen.dart @@ -39,6 +39,7 @@ class _SettingsScreenState extends State bool _isLoading = true; Set _hiddenTransportModes = {}; + bool _hasSetTransportModePreferences = false; Map> _groupedFormulas = {}; // App version info (loaded dynamically from pubspec.yaml) @@ -69,6 +70,8 @@ class _SettingsScreenState extends State final themeMode = await _settingsService.getThemeMode(); final discountType = await _settingsService.getUserDiscountType(); final hiddenModes = await _settingsService.getHiddenTransportModes(); + final hasSetModePrefs = await _settingsService + .hasSetTransportModePreferences(); final locale = await _settingsService.getLocale(); final formulas = await _fareRepository.getAllFormulas(); @@ -103,6 +106,7 @@ class _SettingsScreenState extends State _discountType = discountType; _currentLocale = locale; _hiddenTransportModes = hiddenModes; + _hasSetTransportModePreferences = hasSetModePrefs; _groupedFormulas = grouped; _appVersion = version; _buildNumber = buildNumber; @@ -993,7 +997,11 @@ class _SettingsScreenState extends State // Subtype Toggles using SwitchListTile for test compatibility ...formulas.map((formula) { final modeSubTypeKey = '${formula.mode}::${formula.subType}'; - final isHidden = _hiddenTransportModes.contains(modeSubTypeKey); + // For new users who haven't set any preferences, all modes are hidden (disabled) by default + // For users who have set preferences, check if mode is in the hidden set + final isHidden = + !_hasSetTransportModePreferences || + _hiddenTransportModes.contains(modeSubTypeKey); return SwitchListTile( title: Text( @@ -1021,6 +1029,8 @@ class _SettingsScreenState extends State ); setState(() { + // Mark that user has now set transport mode preferences + _hasSetTransportModePreferences = true; if (shouldHide) { _hiddenTransportModes.add(modeSubTypeKey); } else { diff --git a/lib/src/presentation/widgets/main_screen/location_input_section.dart b/lib/src/presentation/widgets/main_screen/location_input_section.dart index 33cecdc..dba8eed 100644 --- a/lib/src/presentation/widgets/main_screen/location_input_section.dart +++ b/lib/src/presentation/widgets/main_screen/location_input_section.dart @@ -173,7 +173,9 @@ class LocationInputSection extends StatelessWidget { } /// Internal widget for individual location input field with autocomplete. -class _LocationField extends StatelessWidget { +/// Now a StatefulWidget to track search loading state using ValueNotifier +/// to avoid interfering with Autocomplete's internal state. +class _LocationField extends StatefulWidget { final String label; final TextEditingController controller; final bool isOrigin; @@ -194,6 +196,37 @@ class _LocationField extends StatelessWidget { required this.onOpenMapPicker, }); + @override + State<_LocationField> createState() => _LocationFieldState(); +} + +class _LocationFieldState extends State<_LocationField> { + final ValueNotifier _isSearching = ValueNotifier(false); + + @override + void dispose() { + _isSearching.dispose(); + super.dispose(); + } + + Future> _handleOptionsBuilder(String query) async { + if (query.trim().isEmpty) { + _isSearching.value = false; + return const []; + } + + // Set loading state before fetching + _isSearching.value = true; + + try { + final results = await widget.onSearchLocations(query); + return results; + } finally { + // Clear loading state after fetching (success or error) + _isSearching.value = false; + } + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -202,14 +235,11 @@ class _LocationField extends StatelessWidget { builder: (context, constraints) { return Autocomplete( displayStringForOption: (Location option) => option.name, - initialValue: TextEditingValue(text: controller.text), + initialValue: TextEditingValue(text: widget.controller.text), optionsBuilder: (TextEditingValue textEditingValue) async { - if (textEditingValue.text.trim().isEmpty) { - return const Iterable.empty(); - } - return onSearchLocations(textEditingValue.text); + return _handleOptionsBuilder(textEditingValue.text); }, - onSelected: onLocationSelected, + onSelected: widget.onLocationSelected, fieldViewBuilder: ( BuildContext context, @@ -218,64 +248,87 @@ class _LocationField extends StatelessWidget { VoidCallback onFieldSubmitted, ) { // Sync controller text - if (isOrigin && controller.text != textEditingController.text) { - textEditingController.text = controller.text; + if (widget.isOrigin && + widget.controller.text != textEditingController.text) { + textEditingController.text = widget.controller.text; } return Semantics( - label: 'Input for $label location', + label: 'Input for ${widget.label} location', textField: true, - child: TextField( - controller: textEditingController, - focusNode: focusNode, - style: Theme.of(context).textTheme.bodyLarge, - decoration: InputDecoration( - hintText: label, - filled: true, - fillColor: colorScheme.surfaceContainerLowest, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isOrigin && isLoadingLocation) - const Padding( - padding: EdgeInsets.all(12), - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, + child: ValueListenableBuilder( + valueListenable: _isSearching, + builder: (context, isSearching, child) { + return TextField( + controller: textEditingController, + focusNode: focusNode, + style: Theme.of(context).textTheme.bodyLarge, + decoration: InputDecoration( + hintText: widget.label, + filled: true, + fillColor: colorScheme.surfaceContainerLowest, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Show loading indicator when searching for suggestions + if (isSearching) + Padding( + padding: const EdgeInsets.all(12), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ), + ) + // Show current location loading indicator (origin only) + else if (widget.isOrigin && + widget.isLoadingLocation) + const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ) + // Show current location button (origin only) + else if (widget.isOrigin && + widget.onUseCurrentLocation != null) + IconButton( + icon: Icon( + Icons.my_location, + color: colorScheme.primary, + size: 20, + ), + tooltip: 'Use my current location', + onPressed: widget.onUseCurrentLocation, ), + IconButton( + icon: Icon( + Icons.map_outlined, + color: colorScheme.onSurfaceVariant, + size: 20, + ), + tooltip: 'Select from map', + onPressed: widget.onOpenMapPicker, ), - ) - else if (isOrigin && onUseCurrentLocation != null) - IconButton( - icon: Icon( - Icons.my_location, - color: colorScheme.primary, - size: 20, - ), - tooltip: 'Use my current location', - onPressed: onUseCurrentLocation, - ), - IconButton( - icon: Icon( - Icons.map_outlined, - color: colorScheme.onSurfaceVariant, - size: 20, - ), - tooltip: 'Select from map', - onPressed: onOpenMapPicker, + ], ), - ], - ), - ), + ), + ); + }, ), ); }, diff --git a/lib/src/presentation/widgets/main_screen/transport_mode_selection_modal.dart b/lib/src/presentation/widgets/main_screen/transport_mode_selection_modal.dart new file mode 100644 index 0000000..064f463 --- /dev/null +++ b/lib/src/presentation/widgets/main_screen/transport_mode_selection_modal.dart @@ -0,0 +1,535 @@ +import 'package:flutter/material.dart'; + +import '../../../models/fare_formula.dart'; +import '../../../models/transport_mode.dart'; +import '../../../services/settings_service.dart'; + +/// A modal bottom sheet that allows users to select which transport modes +/// to enable for fare calculations. This is shown when a new user attempts +/// to calculate fares but has no transport modes enabled. +class TransportModeSelectionModal extends StatefulWidget { + /// The settings service for saving preferences. + final SettingsService settingsService; + + /// All available fare formulas to display as options. + final List availableFormulas; + + /// Called when user confirms their selection. + final VoidCallback? onConfirmed; + + /// Called when user cancels the modal. + final VoidCallback? onCancelled; + + const TransportModeSelectionModal({ + super.key, + required this.settingsService, + required this.availableFormulas, + this.onConfirmed, + this.onCancelled, + }); + + /// Shows the transport mode selection modal as a bottom sheet. + /// Returns true if the user confirmed their selection, false otherwise. + static Future show({ + required BuildContext context, + required SettingsService settingsService, + required List availableFormulas, + }) async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: true, + enableDrag: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (context) => TransportModeSelectionModal( + settingsService: settingsService, + availableFormulas: availableFormulas, + ), + ); + return result ?? false; + } + + @override + State createState() => + _TransportModeSelectionModalState(); +} + +class _TransportModeSelectionModalState + extends State { + /// Set of selected mode-subtype keys (format: "Mode::SubType"). + final Set _selectedModes = {}; + + /// Grouped formulas by mode. + late Map> _groupedFormulas; + + @override + void initState() { + super.initState(); + _groupedFormulas = _groupFormulas(); + } + + Map> _groupFormulas() { + final grouped = >{}; + for (final formula in widget.availableFormulas) { + if (!grouped.containsKey(formula.mode)) { + grouped[formula.mode] = []; + } + grouped[formula.mode]!.add(formula); + } + return grouped; + } + + bool get _hasSelection => _selectedModes.isNotEmpty; + + Future _onConfirm() async { + if (!_hasSelection) return; + + // First, save all selected modes as enabled (not hidden). + // All other modes remain hidden. + for (final formula in widget.availableFormulas) { + final modeSubTypeKey = '${formula.mode}::${formula.subType}'; + final isSelected = _selectedModes.contains(modeSubTypeKey); + // toggleTransportMode: isHidden=true means hidden, isHidden=false means visible + await widget.settingsService.toggleTransportMode( + modeSubTypeKey, + !isSelected, // If selected, should NOT be hidden (isHidden=false) + ); + } + + widget.onConfirmed?.call(); + if (mounted) { + Navigator.of(context).pop(true); + } + } + + void _onCancel() { + widget.onCancelled?.call(); + Navigator.of(context).pop(false); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final mediaQuery = MediaQuery.of(context); + + // Calculate max height (80% of screen) + final maxHeight = mediaQuery.size.height * 0.8; + + return Container( + constraints: BoxConstraints(maxHeight: maxHeight), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Padding( + padding: const EdgeInsets.only(top: 12, bottom: 8), + child: Container( + width: 32, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.directions_bus_rounded, + color: colorScheme.onPrimaryContainer, + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Select Transport Modes', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.tertiary.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon( + Icons.info_outline_rounded, + size: 20, + color: colorScheme.onTertiaryContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Please select at least one transport mode to include ' + 'in your fare calculations.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onTertiaryContainer, + ), + ), + ), + ], + ), + ), + ], + ), + ), + + const Divider(height: 1), + + // Scrollable content + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _buildCategorizedTransportModes(), + ), + ), + ), + + const Divider(height: 1), + + // Bottom action buttons + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _onCancel, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('Cancel'), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: FilledButton( + onPressed: _hasSelection ? _onConfirm : null, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.check_rounded, size: 20), + const SizedBox(width: 8), + Text( + _hasSelection + ? 'Apply (${_selectedModes.length})' + : 'Select at least one', + ), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + List _buildCategorizedTransportModes() { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final widgets = []; + + // Group modes by category + final categorizedModes = >{ + 'Road': [], + 'Rail': [], + 'Water': [], + }; + + for (final modeStr in _groupedFormulas.keys) { + try { + final mode = TransportMode.fromString(modeStr); + final category = mode.category; + final categoryKey = category[0].toUpperCase() + category.substring(1); + + if (categorizedModes.containsKey(categoryKey)) { + categorizedModes[categoryKey]!.add(modeStr); + } + } catch (e) { + continue; + } + } + + // Build UI for each category + for (final category in ['Road', 'Rail', 'Water']) { + final modesInCategory = categorizedModes[category] ?? []; + if (modesInCategory.isEmpty) continue; + + // Category Header + widgets.add( + Padding( + padding: const EdgeInsets.fromLTRB(4, 16, 4, 8), + child: Row( + children: [ + Icon( + _getIconForCategory(category), + size: 20, + color: colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + category, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.primary, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: () => + _selectAllInCategory(category, modesInCategory), + icon: Icon( + _isAllSelectedInCategory(modesInCategory) + ? Icons.deselect_rounded + : Icons.select_all_rounded, + size: 18, + ), + label: Text( + _isAllSelectedInCategory(modesInCategory) + ? 'Deselect' + : 'Select All', + style: theme.textTheme.labelSmall, + ), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + ), + ], + ), + ), + ); + + // Transport Mode Cards for this category + for (final modeStr in modesInCategory) { + try { + final mode = TransportMode.fromString(modeStr); + final formulas = _groupedFormulas[modeStr] ?? []; + + widgets.add(_buildTransportModeSection(mode, formulas)); + } catch (e) { + continue; + } + } + } + + return widgets; + } + + bool _isAllSelectedInCategory(List modesInCategory) { + for (final modeStr in modesInCategory) { + final formulas = _groupedFormulas[modeStr] ?? []; + for (final formula in formulas) { + final key = '${formula.mode}::${formula.subType}'; + if (!_selectedModes.contains(key)) { + return false; + } + } + } + return true; + } + + void _selectAllInCategory(String category, List modesInCategory) { + final allSelected = _isAllSelectedInCategory(modesInCategory); + + setState(() { + for (final modeStr in modesInCategory) { + final formulas = _groupedFormulas[modeStr] ?? []; + for (final formula in formulas) { + final key = '${formula.mode}::${formula.subType}'; + if (allSelected) { + _selectedModes.remove(key); + } else { + _selectedModes.add(key); + } + } + } + }); + } + + Widget _buildTransportModeSection( + TransportMode mode, + List formulas, + ) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Mode Header + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + _getIconForMode(mode), + size: 20, + color: colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + mode.displayName, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + + if (formulas.isNotEmpty) ...[ + const SizedBox(height: 8), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + + // Subtype Checkboxes + ...formulas.map((formula) { + final modeSubTypeKey = '${formula.mode}::${formula.subType}'; + final isSelected = _selectedModes.contains(modeSubTypeKey); + + return CheckboxListTile( + title: Text( + formula.subType, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + subtitle: formula.notes != null && formula.notes!.isNotEmpty + ? Text( + formula.notes!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, + value: isSelected, + onChanged: (bool? value) { + setState(() { + if (value == true) { + _selectedModes.add(modeSubTypeKey); + } else { + _selectedModes.remove(modeSubTypeKey); + } + }); + }, + contentPadding: EdgeInsets.zero, + dense: true, + controlAffinity: ListTileControlAffinity.leading, + activeColor: colorScheme.primary, + checkColor: colorScheme.onPrimary, + ); + }), + ], + ], + ), + ); + } + + IconData _getIconForCategory(String category) { + switch (category.toLowerCase()) { + case 'road': + return Icons.directions_car_rounded; + case 'rail': + return Icons.train_rounded; + case 'water': + return Icons.directions_boat_rounded; + default: + return Icons.help_outline_rounded; + } + } + + IconData _getIconForMode(TransportMode mode) { + switch (mode) { + case TransportMode.jeepney: + return Icons.directions_bus_rounded; + case TransportMode.bus: + return Icons.airport_shuttle_rounded; + case TransportMode.taxi: + return Icons.local_taxi_rounded; + case TransportMode.train: + return Icons.train_rounded; + case TransportMode.ferry: + return Icons.directions_boat_rounded; + case TransportMode.tricycle: + return Icons.pedal_bike_rounded; + case TransportMode.uvExpress: + return Icons.local_shipping_rounded; + case TransportMode.van: + return Icons.airport_shuttle_rounded; + case TransportMode.motorcycle: + return Icons.two_wheeler_rounded; + case TransportMode.edsaCarousel: + return Icons.directions_bus_filled_rounded; + case TransportMode.pedicab: + return Icons.directions_bike_rounded; + case TransportMode.kuliglig: + return Icons.agriculture_rounded; + } + } +} diff --git a/lib/src/presentation/widgets/main_screen/travel_options_bar.dart b/lib/src/presentation/widgets/main_screen/travel_options_bar.dart index 168db7b..5e68a40 100644 --- a/lib/src/presentation/widgets/main_screen/travel_options_bar.dart +++ b/lib/src/presentation/widgets/main_screen/travel_options_bar.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import '../../../services/fare_comparison_service.dart'; /// A horizontal scrollable bar displaying travel options like passenger count, -/// discount indicator, and sort criteria. +/// discount indicator, sort criteria, and transport mode quick-access. class TravelOptionsBar extends StatelessWidget { final int regularPassengers; final int discountedPassengers; @@ -11,6 +11,15 @@ class TravelOptionsBar extends StatelessWidget { final VoidCallback onPassengerTap; final ValueChanged onSortChanged; + /// Number of enabled transport modes. + final int enabledModesCount; + + /// Total number of available transport modes. + final int totalModesCount; + + /// Callback when the transport modes button is tapped. + final VoidCallback? onTransportModesTap; + const TravelOptionsBar({ super.key, required this.regularPassengers, @@ -18,6 +27,9 @@ class TravelOptionsBar extends StatelessWidget { required this.sortCriteria, required this.onPassengerTap, required this.onSortChanged, + this.enabledModesCount = 0, + this.totalModesCount = 0, + this.onTransportModesTap, }); int get totalPassengers => regularPassengers + discountedPassengers; @@ -77,6 +89,50 @@ class TravelOptionsBar extends StatelessWidget { ), ), const SizedBox(width: 8), + // Transport Modes Quick Access Chip + if (onTransportModesTap != null) + Semantics( + label: + 'Transport modes: $enabledModesCount of $totalModesCount enabled. Tap to change.', + button: true, + child: ActionChip( + avatar: Badge( + label: Text( + '$enabledModesCount', + style: textTheme.labelSmall?.copyWith( + color: colorScheme.onPrimary, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: enabledModesCount > 0 + ? colorScheme.primary + : colorScheme.error, + child: Icon( + Icons.directions_bus_rounded, + size: 18, + color: colorScheme.primary, + ), + ), + label: Text( + 'Modes', + style: textTheme.labelLarge?.copyWith( + color: colorScheme.onSurface, + ), + ), + backgroundColor: colorScheme.surfaceContainerLowest, + side: BorderSide( + color: enabledModesCount > 0 + ? colorScheme.outlineVariant + : colorScheme.error.withValues(alpha: 0.5), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + onPressed: onTransportModesTap, + ), + ), + const SizedBox(width: 8), // Sort Chip Semantics( label: diff --git a/lib/src/services/settings_service.dart b/lib/src/services/settings_service.dart index 5ed51a4..bba687f 100644 --- a/lib/src/services/settings_service.dart +++ b/lib/src/services/settings_service.dart @@ -15,6 +15,8 @@ class SettingsService { static const String _keyUserDiscountType = 'user_discount_type'; static const String _keyHasSetDiscountType = 'has_set_discount_type'; static const String _keyHiddenTransportModes = 'hidden_transport_modes'; + static const String _keyHasSetTransportModePreferences = + 'has_set_transport_mode_preferences'; static const String _keyLastLatitude = 'last_known_latitude'; static const String _keyLastLongitude = 'last_known_longitude'; static const String _keyLastLocationName = 'last_known_location_name'; @@ -143,7 +145,17 @@ class SettingsService { return prefs.getBool(_keyHasSetDiscountType) ?? false; } - /// Get the list of hidden transport modes (stored as "Mode::SubType" strings) + /// Check if the user has ever set their transport mode preferences. + /// New users (false) should have all modes disabled by default. + Future hasSetTransportModePreferences() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_keyHasSetTransportModePreferences) ?? false; + } + + /// Get the list of hidden transport modes (stored as "Mode::SubType" strings). + /// NOTE: For new users who haven't set preferences yet, this returns an empty set. + /// The caller should use hasSetTransportModePreferences() to check if all modes + /// should be treated as hidden (disabled) by default. Future> getHiddenTransportModes() async { final prefs = await SharedPreferences.getInstance(); final hiddenList = prefs.getStringList(_keyHiddenTransportModes); @@ -164,10 +176,18 @@ class SettingsService { } await prefs.setStringList(_keyHiddenTransportModes, hiddenModes.toList()); + // Mark that the user has now set their transport mode preferences + await prefs.setBool(_keyHasSetTransportModePreferences, true); } - /// Check if a specific mode-subtype combination is hidden + /// Check if a specific mode-subtype combination is hidden. + /// Takes into account whether user has set preferences (new users = all hidden). Future isTransportModeHidden(String mode, String subType) async { + final hasSetPrefs = await hasSetTransportModePreferences(); + // For new users who haven't set any preferences, all modes are hidden by default + if (!hasSetPrefs) { + return true; + } final hiddenModes = await getHiddenTransportModes(); return hiddenModes.contains('$mode::$subType'); } diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index 973be55..63af9da 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -153,6 +153,13 @@ class MockSettingsService implements SettingsService { return hasSetDiscount; } + bool hasSetTransportModePrefs = true; // Default true for existing tests + + @override + Future hasSetTransportModePreferences() async { + return hasSetTransportModePrefs; + } + @override Future> getEnabledModes() async { return hiddenTransportModes; diff --git a/test/screens/main_screen_test.dart b/test/screens/main_screen_test.dart index 33fec8c..ea72236 100644 --- a/test/screens/main_screen_test.dart +++ b/test/screens/main_screen_test.dart @@ -185,7 +185,14 @@ void main() { const Duration(milliseconds: 900), ); // wait for autocomplete debounce (800ms) + buffer await tester.pumpAndSettle(); - await tester.tap(find.text('Luneta').last); // Select from options + // Find the option in the dropdown list tile (not the TextField) + final listTileFinder = find.descendant( + of: find.byType(ListTile), + matching: find.text('Luneta'), + ); + if (listTileFinder.evaluate().isNotEmpty) { + await tester.tap(listTileFinder.first); + } await tester.pumpAndSettle(); // Close options // 2. Search and select Destination @@ -198,7 +205,14 @@ void main() { const Duration(milliseconds: 900), ); // wait for autocomplete debounce (800ms) + buffer await tester.pumpAndSettle(); - await tester.tap(find.text('MOA').last); + // Find the option in the dropdown list tile (not the TextField) + final moaListTileFinder = find.descendant( + of: find.byType(ListTile), + matching: find.text('MOA'), + ); + if (moaListTileFinder.evaluate().isNotEmpty) { + await tester.tap(moaListTileFinder.first); + } await tester.pumpAndSettle(); // 3. Tap Calculate diff --git a/test/screens/onboarding_localization_test.dart b/test/screens/onboarding_localization_test.dart index e87ff59..2697724 100644 --- a/test/screens/onboarding_localization_test.dart +++ b/test/screens/onboarding_localization_test.dart @@ -89,6 +89,12 @@ class FakeSettingsService implements SettingsService { return hasSetDiscount; } + bool hasSetTransportModePrefs = true; // Default true for existing tests + @override + Future hasSetTransportModePreferences() async { + return hasSetTransportModePrefs; + } + @override Future> getEnabledModes() async { return hiddenTransportModes; From 5e60b90ca312b27cd408995f5a8ff0fd4afacda6 Mon Sep 17 00:00:00 2001 From: MasuRii Date: Thu, 1 Jan 2026 12:17:03 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20enhance=20?= =?UTF-8?q?discount=20handling=20and=20transport=20mode=20UX=20persistence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add passenger state management when setting discount type - Fix location search loading indicator timing with post-frame callback - Load saved transport mode preferences on modal initialization - Clean up injection.config.dart formatting --- lib/src/core/di/injection.config.dart | 65 ++++++++----------- .../controllers/main_screen_controller.dart | 18 ++++- .../main_screen/location_input_section.dart | 7 +- .../transport_mode_selection_modal.dart | 17 +++++ 4 files changed, 66 insertions(+), 41 deletions(-) diff --git a/lib/src/core/di/injection.config.dart b/lib/src/core/di/injection.config.dart index 9f2e387..510b3e9 100644 --- a/lib/src/core/di/injection.config.dart +++ b/lib/src/core/di/injection.config.dart @@ -27,56 +27,47 @@ import '../../services/transport_mode_filter_service.dart' as _i263; import '../hybrid_engine.dart' as _i210; extension GetItInjectableX on _i174.GetIt { - // initializes the registration of main-scope dependencies inside of GetIt +// initializes the registration of main-scope dependencies inside of GetIt _i174.GetIt init({ String? environment, _i526.EnvironmentFilter? environmentFilter, }) { - final gh = _i526.GetItHelper(this, environment, environmentFilter); + final gh = _i526.GetItHelper( + this, + environment, + environmentFilter, + ); gh.singleton<_i68.FareRepository>(() => _i68.FareRepository()); gh.singleton<_i583.SettingsService>(() => _i583.SettingsService()); gh.lazySingleton<_i1024.RegionRepository>(() => _i1024.RegionRepository()); gh.lazySingleton<_i831.ConnectivityService>( - () => _i831.ConnectivityService(), - ); + () => _i831.ConnectivityService()); gh.lazySingleton<_i838.HaversineRoutingService>( - () => _i838.HaversineRoutingService(), - ); + () => _i838.HaversineRoutingService()); gh.lazySingleton<_i570.OsrmRoutingService>( - () => _i570.OsrmRoutingService(), - ); + () => _i570.OsrmRoutingService()); gh.lazySingleton<_i1015.RouteCacheService>( - () => _i1015.RouteCacheService(), - ); + () => _i1015.RouteCacheService()); gh.lazySingleton<_i263.TransportModeFilterService>( - () => _i263.TransportModeFilterService(), - ); + () => _i263.TransportModeFilterService()); gh.lazySingleton<_i639.GeocodingService>( - () => _i639.OpenStreetMapGeocodingService(), - ); - gh.lazySingleton<_i805.OfflineMapService>( - () => _i805.OfflineMapService( - gh<_i831.ConnectivityService>(), - gh<_i1024.RegionRepository>(), - ), - ); - gh.lazySingleton<_i67.RoutingService>( - () => _i589.RoutingServiceManager( - gh<_i570.OsrmRoutingService>(), - gh<_i838.HaversineRoutingService>(), - gh<_i1015.RouteCacheService>(), - gh<_i831.ConnectivityService>(), - ), - ); - gh.lazySingleton<_i758.FareComparisonService>( - () => _i758.FareComparisonService(gh<_i263.TransportModeFilterService>()), - ); - gh.lazySingleton<_i210.HybridEngine>( - () => _i210.HybridEngine( - gh<_i67.RoutingService>(), - gh<_i583.SettingsService>(), - ), - ); + () => _i639.OpenStreetMapGeocodingService()); + gh.lazySingleton<_i805.OfflineMapService>(() => _i805.OfflineMapService( + gh<_i831.ConnectivityService>(), + gh<_i1024.RegionRepository>(), + )); + gh.lazySingleton<_i67.RoutingService>(() => _i589.RoutingServiceManager( + gh<_i570.OsrmRoutingService>(), + gh<_i838.HaversineRoutingService>(), + gh<_i1015.RouteCacheService>(), + gh<_i831.ConnectivityService>(), + )); + gh.lazySingleton<_i758.FareComparisonService>(() => + _i758.FareComparisonService(gh<_i263.TransportModeFilterService>())); + gh.lazySingleton<_i210.HybridEngine>(() => _i210.HybridEngine( + gh<_i67.RoutingService>(), + gh<_i583.SettingsService>(), + )); return this; } } diff --git a/lib/src/presentation/controllers/main_screen_controller.dart b/lib/src/presentation/controllers/main_screen_controller.dart index ef9be17..c6b2cb9 100644 --- a/lib/src/presentation/controllers/main_screen_controller.dart +++ b/lib/src/presentation/controllers/main_screen_controller.dart @@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart'; import '../../core/di/injection.dart'; import '../../core/errors/failures.dart'; import '../../core/hybrid_engine.dart'; +import '../../models/discount_type.dart'; import '../../models/fare_formula.dart'; import '../../models/fare_result.dart'; import '../../models/location.dart'; @@ -140,9 +141,22 @@ class MainScreenController extends ChangeNotifier { return _settingsService.hasSetDiscountType(); } - /// Set user discount type preference - Future setUserDiscountType(dynamic discountType) async { + /// Set user discount type preference and update local passenger state. + Future setUserDiscountType(DiscountType discountType) async { await _settingsService.setUserDiscountType(discountType); + + // Update local passenger counts based on the selected discount type + // Assuming a total of 1 passenger when first setting the preference + if (discountType == DiscountType.discounted) { + _regularPassengers = 0; + _discountedPassengers = 1; + } else { + _regularPassengers = 1; + _discountedPassengers = 0; + } + _passengerCount = _regularPassengers + _discountedPassengers; + + notifyListeners(); } /// Search locations with debounce for autocomplete diff --git a/lib/src/presentation/widgets/main_screen/location_input_section.dart b/lib/src/presentation/widgets/main_screen/location_input_section.dart index dba8eed..48f23ba 100644 --- a/lib/src/presentation/widgets/main_screen/location_input_section.dart +++ b/lib/src/presentation/widgets/main_screen/location_input_section.dart @@ -222,8 +222,11 @@ class _LocationFieldState extends State<_LocationField> { final results = await widget.onSearchLocations(query); return results; } finally { - // Clear loading state after fetching (success or error) - _isSearching.value = false; + // Delay clearing the loading state until after the current frame completes + // so the autocomplete options have time to render before the spinner disappears. + WidgetsBinding.instance.addPostFrameCallback((_) { + _isSearching.value = false; + }); } } diff --git a/lib/src/presentation/widgets/main_screen/transport_mode_selection_modal.dart b/lib/src/presentation/widgets/main_screen/transport_mode_selection_modal.dart index 064f463..138dc06 100644 --- a/lib/src/presentation/widgets/main_screen/transport_mode_selection_modal.dart +++ b/lib/src/presentation/widgets/main_screen/transport_mode_selection_modal.dart @@ -68,6 +68,23 @@ class _TransportModeSelectionModalState void initState() { super.initState(); _groupedFormulas = _groupFormulas(); + _loadSavedPreferences(); + } + + /// Loads saved transport mode preferences and initializes selected modes. + /// Modes that are NOT in the hidden set should be marked as selected. + Future _loadSavedPreferences() async { + final hiddenModes = await widget.settingsService.getHiddenTransportModes(); + + setState(() { + for (final formula in widget.availableFormulas) { + final modeSubTypeKey = '${formula.mode}::${formula.subType}'; + // Mode is selected if it's NOT hidden + if (!hiddenModes.contains(modeSubTypeKey)) { + _selectedModes.add(modeSubTypeKey); + } + } + }); } Map> _groupFormulas() { From c84725636301e948a3164ff80e473b6ea766581c Mon Sep 17 00:00:00 2001 From: MasuRii Date: Thu, 1 Jan 2026 14:06:28 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=A8=20feat:=20implement=20location=20?= =?UTF-8?q?suggestions=20and=20comprehensive=20transport=20mode=20UX=20imp?= =?UTF-8?q?rovements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set default transport modes to Jeepney/Bus/Taxi only - Add word-wrap for origin/destination input fields (2-line limit) - Implement fare results persistence on route swap - Add real address display in map picker with debounce - Introduce Lowest Overall sort option for fare comparison - Fix storage bar accuracy in region management - Replace brackets with transport mode type chips - Improve fare result display with base name + chip formatting - Fix input field alignment (swap button and route indicator) - Correct per-person price calculation logic - Bump version to 2.2.0+4 - Update GitHub workflow for release notes generation --- .github/workflows/release-apk.yml | 44 +- lib/src/models/map_region.dart | 15 +- lib/src/models/transport_mode.dart | 38 ++ .../controllers/main_screen_controller.dart | 21 +- lib/src/presentation/screens/main_screen.dart | 13 +- .../screens/map_picker_screen.dart | 124 +++++- .../widgets/fare_result_card.dart | 62 ++- .../main_screen/fare_results_list.dart | 63 ++- .../main_screen/location_input_section.dart | 399 +++++++++++------- .../transport_mode_selection_modal.dart | 19 +- .../main_screen/travel_options_bar.dart | 106 ++++- lib/src/services/fare_comparison_service.dart | 18 +- lib/src/services/settings_service.dart | 27 +- pubspec.yaml | 2 +- test/models/map_region_test.dart | 39 +- test/screens/region_download_screen_test.dart | 20 +- 16 files changed, 768 insertions(+), 242 deletions(-) diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml index 97f5935..0811ada 100644 --- a/.github/workflows/release-apk.yml +++ b/.github/workflows/release-apk.yml @@ -197,26 +197,50 @@ jobs: | **ARM32 (armeabi-v7a)** | `${{ steps.apk_files.outputs.apk_armv7 }}` | | **x86_64** | `${{ steps.apk_files.outputs.apk_x86_64 }}` | - ### Changes + ## ✨ What's New EOF - # Try to get commits since last tag, or last 10 commits if no previous tag + # Determine range LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - if [ -n "$LAST_TAG" ]; then - echo "Changes since $LAST_TAG:" >> RELEASE_NOTES.md - echo "" >> RELEASE_NOTES.md - git log ${LAST_TAG}..HEAD --pretty=format:"- %s (%h)" --no-merges >> RELEASE_NOTES.md + RANGE="${LAST_TAG}..HEAD" + echo "Generating notes for range: $RANGE" + else + RANGE="HEAD~10..HEAD" + echo "No previous tag found, using last 10 commits" + fi + + # Features + echo "πŸš€ **New Features:**" >> RELEASE_NOTES.md + FEATURES=$(git log ${RANGE} --grep="✨\|feat" --pretty=format:"- %s" --no-merges || echo "") + if [ -n "$FEATURES" ]; then + echo "$FEATURES" >> RELEASE_NOTES.md else - echo "Recent commits:" >> RELEASE_NOTES.md - echo "" >> RELEASE_NOTES.md - git log -10 --pretty=format:"- %s (%h)" --no-merges >> RELEASE_NOTES.md + echo "- General improvements and updates" >> RELEASE_NOTES.md fi + echo "" >> RELEASE_NOTES.md + # Bug Fixes + echo "πŸ› **Bug Fixes:**" >> RELEASE_NOTES.md + FIXES=$(git log ${RANGE} --grep="πŸ›\|fix" --pretty=format:"- %s" --no-merges || echo "") + if [ -n "$FIXES" ]; then + echo "$FIXES" >> RELEASE_NOTES.md + else + echo "- Stability improvements" >> RELEASE_NOTES.md + fi echo "" >> RELEASE_NOTES.md + + # Improvements + echo "🎨 **Improvements:**" >> RELEASE_NOTES.md + IMPROVEMENTS=$(git log ${RANGE} --grep="🎨\|♻️\|πŸ—\|πŸ’„\|refactor\|perf" --pretty=format:"- %s" --no-merges || echo "") + if [ -n "$IMPROVEMENTS" ]; then + echo "$IMPROVEMENTS" >> RELEASE_NOTES.md + else + echo "- UI refinements and performance tweaks" >> RELEASE_NOTES.md + fi + echo "" >> RELEASE_NOTES.md echo "---" >> RELEASE_NOTES.md - echo "" >> RELEASE_NOTES.md echo "_Built with Flutter on GitHub Actions_" >> RELEASE_NOTES.md # Output for debugging diff --git a/lib/src/models/map_region.dart b/lib/src/models/map_region.dart index 5db599c..f3b18aa 100644 --- a/lib/src/models/map_region.dart +++ b/lib/src/models/map_region.dart @@ -502,10 +502,21 @@ class StorageInfo { /// Available storage in GB. double get availableGB => availableBytes / (1024 * 1024 * 1024); - /// Total used percentage. - double get usedPercentage => + /// Total device storage used percentage (for informational purposes). + double get deviceUsedPercentage => totalBytes > 0 ? (totalBytes - availableBytes) / totalBytes : 0.0; + /// Map cache usage as a percentage of total usable space (cache + available). + /// This is what the storage bar should display: + /// - When cache is 0 and available is 10GB, bar shows nearly empty (0%) + /// - When cache is 5GB and available is 5GB, bar shows 50% + /// - When cache is 10GB and available is 0, bar shows 100% + double get usedPercentage { + final totalUsableSpace = mapCacheBytes + availableBytes; + if (totalUsableSpace <= 0) return 0.0; + return mapCacheBytes / totalUsableSpace; + } + /// Formatted string for map cache size. String get mapCacheFormatted { if (mapCacheBytes < 1024 * 1024) { diff --git a/lib/src/models/transport_mode.dart b/lib/src/models/transport_mode.dart index 5ac06f7..1e07c7b 100644 --- a/lib/src/models/transport_mode.dart +++ b/lib/src/models/transport_mode.dart @@ -107,4 +107,42 @@ enum TransportMode { orElse: () => TransportMode.jeepney, // Default fallback ); } + + /// Parses a transport mode string into its base mode name and optional subtype. + /// + /// Examples: + /// - "Jeepney (Traditional)" -> ("Jeepney", "Traditional") + /// - "Bus (Aircon)" -> ("Bus", "Aircon") + /// - "Jeepney (Modern (PUJ))" -> ("Jeepney", "Modern (PUJ)") + /// - "Taxi" -> ("Taxi", null) + /// + /// Returns a record with baseName and subtype (nullable). + static ({String baseName, String? subtype}) parseTransportMode(String mode) { + final trimmed = mode.trim(); + + // Find the first opening parenthesis + final firstParenIndex = trimmed.indexOf('('); + + if (firstParenIndex == -1) { + // No subtype + return (baseName: trimmed, subtype: null); + } + + // Extract base name (everything before the first '(') + final baseName = trimmed.substring(0, firstParenIndex).trim(); + + // Extract subtype (everything between first '(' and last ')') + final lastParenIndex = trimmed.lastIndexOf(')'); + if (lastParenIndex <= firstParenIndex) { + // Malformed string, treat as no subtype + return (baseName: baseName, subtype: null); + } + + final subtype = trimmed.substring(firstParenIndex + 1, lastParenIndex).trim(); + + return ( + baseName: baseName, + subtype: subtype.isNotEmpty ? subtype : null, + ); + } } diff --git a/lib/src/presentation/controllers/main_screen_controller.dart b/lib/src/presentation/controllers/main_screen_controller.dart index c6b2cb9..1404a77 100644 --- a/lib/src/presentation/controllers/main_screen_controller.dart +++ b/lib/src/presentation/controllers/main_screen_controller.dart @@ -218,7 +218,9 @@ class MainScreenController extends ChangeNotifier { } } - /// Swap origin and destination + /// Swap origin and destination. + /// Note: Fare results are preserved on swap since the route distance + /// remains the same (just reversed direction). void swapLocations() { if (_originLocation == null && _destinationLocation == null) return; @@ -231,7 +233,12 @@ class MainScreenController extends ChangeNotifier { _destinationLocation = tempLocation; _destinationLatLng = tempLatLng; - _resetResult(); + // Note: We do NOT reset fare results on swap. + // The fare is based on distance which is the same regardless of direction. + // Only clear route points so they can be recalculated. + _routePoints = []; + _routeResult = null; + notifyListeners(); if (_originLocation != null && _destinationLocation != null) { @@ -311,10 +318,18 @@ class MainScreenController extends ChangeNotifier { final List results = []; final trafficFactor = await _settingsService.getTrafficFactor(); + final hasSetPrefs = await _settingsService.hasSetTransportModePreferences(); final hiddenModes = await _settingsService.getHiddenTransportModes(); final visibleFormulas = _availableFormulas.where((formula) { final modeSubTypeKey = '${formula.mode}::${formula.subType}'; + + if (!hasSetPrefs) { + // New user - use default enabled modes + final defaultModes = SettingsService.getDefaultEnabledModes(); + return defaultModes.contains(modeSubTypeKey); + } + // Existing user - check hidden modes return !hiddenModes.contains(modeSubTypeKey); }).toList(); @@ -352,7 +367,7 @@ class MainScreenController extends ChangeNotifier { results.add( FareResult( transportMode: '${formula.mode} (${formula.subType})', - fare: fare, + fare: _passengerCount > 0 ? fare / _passengerCount : fare, indicatorLevel: indicator, isRecommended: false, passengerCount: _passengerCount, diff --git a/lib/src/presentation/screens/main_screen.dart b/lib/src/presentation/screens/main_screen.dart index 2d66dc3..ce14701 100644 --- a/lib/src/presentation/screens/main_screen.dart +++ b/lib/src/presentation/screens/main_screen.dart @@ -83,6 +83,7 @@ class _MainScreenState extends State { Future _loadTransportModeCounts() async { try { final allFormulas = await _fareRepository.getAllFormulas(); + final hasSetPrefs = await _settingsService.hasSetTransportModePreferences(); final hiddenModes = await _settingsService.getHiddenTransportModes(); // Count unique mode-subtype combinations @@ -91,9 +92,15 @@ class _MainScreenState extends State { allModeKeys.add('${formula.mode}::${formula.subType}'); } - final enabledCount = allModeKeys - .where((key) => !hiddenModes.contains(key)) - .length; + int enabledCount; + if (!hasSetPrefs) { + // New user - count default enabled modes that exist in formulas + final defaultModes = SettingsService.getDefaultEnabledModes(); + enabledCount = allModeKeys.where((key) => defaultModes.contains(key)).length; + } else { + // Existing user - count modes not in hidden set + enabledCount = allModeKeys.where((key) => !hiddenModes.contains(key)).length; + } if (mounted) { setState(() { diff --git a/lib/src/presentation/screens/map_picker_screen.dart b/lib/src/presentation/screens/map_picker_screen.dart index 928a541..5148371 100644 --- a/lib/src/presentation/screens/map_picker_screen.dart +++ b/lib/src/presentation/screens/map_picker_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; @@ -36,8 +38,16 @@ class _MapPickerScreenState extends State LatLng? _selectedLocation; bool _isMapMoving = false; final ValueNotifier _isSearchingLocation = ValueNotifier(false); + final ValueNotifier _isLoadingAddress = ValueNotifier(false); String _addressText = 'Move map to select location'; + /// Debounce timer for reverse geocoding to avoid excessive API calls + /// during rapid map movements + Timer? _geocodeDebounceTimer; + + /// Debounce duration for reverse geocoding (400ms) + static const Duration _geocodeDebounceDuration = Duration(milliseconds: 400); + // Animation controller for pin bounce effect late final AnimationController _pinAnimationController; late final Animation _pinBounceAnimation; @@ -83,8 +93,10 @@ class _MapPickerScreenState extends State @override void dispose() { + _geocodeDebounceTimer?.cancel(); _pinAnimationController.dispose(); _isSearchingLocation.dispose(); + _isLoadingAddress.dispose(); super.dispose(); } @@ -92,13 +104,15 @@ class _MapPickerScreenState extends State if (event is MapEventMoveStart) { setState(() => _isMapMoving = true); _pinAnimationController.forward(); + // Cancel any pending geocode request when movement starts + _geocodeDebounceTimer?.cancel(); } else if (event is MapEventMoveEnd) { setState(() { _isMapMoving = false; _selectedLocation = _mapController.camera.center; }); _pinAnimationController.reverse(); - _updateAddress(_mapController.camera.center); + _debouncedUpdateAddress(_mapController.camera.center); } else if (event is MapEventMove) { // Update position during movement setState(() { @@ -107,15 +121,48 @@ class _MapPickerScreenState extends State } } - void _updateAddress(LatLng location) { - // In a real app, this would call a geocoding service - // For now, we display the coordinates in a formatted way - setState(() { - _addressText = - '${location.latitude.toStringAsFixed(6)}, ${location.longitude.toStringAsFixed(6)}'; + /// Debounced reverse geocoding to reduce API calls during rapid map movements. + /// Cancels any pending request and schedules a new one after the debounce period. + void _debouncedUpdateAddress(LatLng location) { + // Cancel any existing pending request + _geocodeDebounceTimer?.cancel(); + + // Show loading indicator immediately for better UX + _isLoadingAddress.value = true; + + // Schedule the actual geocoding after the debounce period + _geocodeDebounceTimer = Timer(_geocodeDebounceDuration, () { + _updateAddress(location); }); } + void _updateAddress(LatLng location) async { + // Perform reverse geocoding to get the human-readable address + _isLoadingAddress.value = true; + + try { + final address = await _geocodingService.getAddressFromLatLng( + location.latitude, + location.longitude, + ); + if (mounted) { + setState(() { + _addressText = address.name; + }); + } + } catch (e) { + // Fallback to coordinates if geocoding fails + if (mounted) { + setState(() { + _addressText = + '${location.latitude.toStringAsFixed(6)}, ${location.longitude.toStringAsFixed(6)}'; + }); + } + } finally { + _isLoadingAddress.value = false; + } + } + void _confirmLocation() { if (_selectedLocation != null) { Navigator.pop(context, _selectedLocation); @@ -610,19 +657,56 @@ class _MapPickerScreenState extends State const SizedBox(height: 4), AnimatedSwitcher( duration: const Duration(milliseconds: 200), - child: Text( - _isMapMoving ? 'Moving...' : _addressText, - key: ValueKey( - _isMapMoving ? 'moving' : _addressText, - ), - style: theme.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w600, - color: _isMapMoving - ? colorScheme.onSurfaceVariant - : colorScheme.onSurface, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, + child: ValueListenableBuilder( + valueListenable: _isLoadingAddress, + builder: (context, isLoading, child) { + if (_isMapMoving) { + return Text( + 'Moving...', + key: const ValueKey('moving'), + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ); + } else if (isLoading) { + return Row( + key: const ValueKey('loading'), + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 8), + Text( + 'Getting address...', + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ); + } else { + return Text( + _addressText, + key: ValueKey(_addressText), + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ); + } + }, ), ), ], diff --git a/lib/src/presentation/widgets/fare_result_card.dart b/lib/src/presentation/widgets/fare_result_card.dart index c9e9799..51c40de 100644 --- a/lib/src/presentation/widgets/fare_result_card.dart +++ b/lib/src/presentation/widgets/fare_result_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../core/theme/transit_colors.dart'; import '../../models/fare_result.dart'; +import '../../models/transport_mode.dart'; /// A modern, accessible fare result card widget. /// @@ -202,20 +203,32 @@ class FareResultCard extends StatelessWidget { Widget _buildInfoSection(BuildContext context, Color statusColor) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; + + // Parse transport mode to extract base name and subtype + final parsed = TransportMode.parseTransportMode(transportMode); + final baseName = parsed.baseName; + final subtype = parsed.subtype; return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - // Transport mode name - Text( - transportMode, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + // Transport mode name with optional subtype chip + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + runSpacing: 4, + children: [ + Text( + baseName, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + if (subtype != null) + _buildSubtypeChip(context, subtype, statusColor), + ], ), const SizedBox(height: 4), // Distance and time info row @@ -283,12 +296,41 @@ class FareResultCard extends StatelessWidget { ], ); } + + /// Builds a styled chip/tag for the transport subtype. + Widget _buildSubtypeChip(BuildContext context, String subtype, Color statusColor) { + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: statusColor.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Text( + subtype, + style: theme.textTheme.labelSmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + ); + } /// Builds the price display section. Widget _buildPriceSection(BuildContext context, Color statusColor) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; + // Calculate per-person fare: total divided by passenger count + // Guard against division by zero (should not happen, but be safe) + final perPersonFare = passengerCount > 0 ? totalFare / passengerCount : totalFare; + return Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.center, @@ -318,7 +360,7 @@ class FareResultCard extends StatelessWidget { if (passengerCount > 1) ...[ const SizedBox(height: 2), Text( - 'β‚±${fare.toStringAsFixed(2)}/pax', + 'β‚±${perPersonFare.toStringAsFixed(2)}/pax', style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), diff --git a/lib/src/presentation/widgets/main_screen/fare_results_list.dart b/lib/src/presentation/widgets/main_screen/fare_results_list.dart index 0d38ded..01de48b 100644 --- a/lib/src/presentation/widgets/main_screen/fare_results_list.dart +++ b/lib/src/presentation/widgets/main_screen/fare_results_list.dart @@ -5,7 +5,11 @@ import '../../../models/transport_mode.dart'; import '../../../services/fare_comparison_service.dart'; import '../fare_result_card.dart'; -/// A widget that displays grouped fare results by transport mode. +/// A widget that displays fare results. +/// +/// When [sortCriteria] is [SortCriteria.lowestOverall], results are displayed +/// as a flat list sorted by price (lowest first) without category headers. +/// For other sort criteria, results are grouped by transport mode category. class FareResultsList extends StatelessWidget { final List fareResults; final SortCriteria sortCriteria; @@ -20,6 +24,60 @@ class FareResultsList extends StatelessWidget { @override Widget build(BuildContext context) { + // For "Lowest Overall" sort, display a flat list without mode grouping + if (sortCriteria == SortCriteria.lowestOverall) { + return _buildFlatList(context); + } + + // For other sort criteria, display grouped by transport mode + return _buildGroupedList(context); + } + + /// Builds a flat list of fare results sorted by price (lowest first). + /// No category headers - just a simple sorted list. + Widget _buildFlatList(BuildContext context) { + // Sort fare results by total fare ascending + final sortedFares = List.from(fareResults) + ..sort((a, b) => a.totalFare.compareTo(b.totalFare)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: sortedFares.asMap().entries.map((entry) { + final index = entry.key; + final result = entry.value; + + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: Duration(milliseconds: 200 + (index * 50)), + curve: Curves.easeOutCubic, + builder: (context, value, child) { + return Opacity( + opacity: value, + child: Transform.translate( + offset: Offset(0, 20 * (1 - value)), + child: child, + ), + ); + }, + child: Padding( + padding: const EdgeInsets.only(bottom: 12), + child: FareResultCard( + transportMode: result.transportMode, + fare: result.fare, + indicatorLevel: result.indicatorLevel, + isRecommended: result.isRecommended, + passengerCount: result.passengerCount, + totalFare: result.totalFare, + // Show base name + subtype chip (consistent across views) + ), + ), + ); + }).toList(), + ); + } + + /// Builds a grouped list of fare results organized by transport mode. + Widget _buildGroupedList(BuildContext context) { final groupedResults = fareComparisonService.groupFaresByMode(fareResults); final sortedGroups = groupedResults.entries.toList(); @@ -76,11 +134,12 @@ class FareResultsList extends StatelessWidget { padding: const EdgeInsets.only(bottom: 12), child: FareResultCard( transportMode: result.transportMode, - fare: result.totalFare, + fare: result.fare, indicatorLevel: result.indicatorLevel, isRecommended: result.isRecommended, passengerCount: result.passengerCount, totalFare: result.totalFare, + // Show base name + subtype chip (consistent with flat list) ), ), ), diff --git a/lib/src/presentation/widgets/main_screen/location_input_section.dart b/lib/src/presentation/widgets/main_screen/location_input_section.dart index 48f23ba..463d486 100644 --- a/lib/src/presentation/widgets/main_screen/location_input_section.dart +++ b/lib/src/presentation/widgets/main_screen/location_input_section.dart @@ -1,11 +1,10 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import '../../../models/location.dart'; /// A card widget containing origin and destination input fields with autocomplete. -class LocationInputSection extends StatelessWidget { +/// Input fields are limited to 2 lines with internal scrolling for longer addresses. +class LocationInputSection extends StatefulWidget { final TextEditingController originController; final TextEditingController destinationController; final bool isLoadingLocation; @@ -30,33 +29,76 @@ class LocationInputSection extends StatelessWidget { required this.onOpenMapPicker, }); - // Fixed dimensions based on InputDecoration contentPadding and text style - // TextField height β‰ˆ contentPadding.vertical * 2 + text line height β‰ˆ 12*2 + 24 = 48 - // Gap between fields = 12 - // Total height = 48 + 12 + 48 = 108 - // Origin icon center is at 24 (half of first field) - // Destination icon (16px) center is at 108 - 8 = 100 - // Line should span from below origin circle (24 + 6 = 30) to above destination pin (100 - 8 = 92) - // Line height = 92 - 30 = 62 - static const double _inputFieldHeight = 48; - static const double _fieldGap = 12; + @override + State createState() => _LocationInputSectionState(); +} + +class _LocationInputSectionState extends State { + // Sizes for route indicator elements static const double _originCircleSize = 12; static const double _destinationIconSize = 16; + static const double _fieldGap = 12; + // Minimum height for input fields to ensure consistent layout + static const double _minFieldHeight = 48; + + // Keys to measure actual field heights for dynamic indicator positioning + final GlobalKey _originFieldKey = GlobalKey(); + final GlobalKey _destinationFieldKey = GlobalKey(); + + // Current measured heights (updated after layout) + double _originFieldHeight = _minFieldHeight; + double _destinationFieldHeight = _minFieldHeight; + + @override + void initState() { + super.initState(); + // Measure field heights after first frame + WidgetsBinding.instance.addPostFrameCallback((_) { + _measureFieldHeights(); + }); + } + + void _measureFieldHeights() { + final originContext = _originFieldKey.currentContext; + final destContext = _destinationFieldKey.currentContext; + + if (originContext != null && destContext != null) { + final originBox = originContext.findRenderObject() as RenderBox?; + final destBox = destContext.findRenderObject() as RenderBox?; + + if (originBox != null && destBox != null && originBox.hasSize && destBox.hasSize) { + final newOriginHeight = originBox.size.height; + final newDestHeight = destBox.size.height; + + if (newOriginHeight != _originFieldHeight || newDestHeight != _destinationFieldHeight) { + setState(() { + _originFieldHeight = newOriginHeight; + _destinationFieldHeight = newDestHeight; + }); + } + } + } + } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - // Total height of both fields + gap - const totalFieldsHeight = _inputFieldHeight * 2 + _fieldGap; + // Schedule a measurement after this frame completes + WidgetsBinding.instance.addPostFrameCallback((_) { + _measureFieldHeights(); + }); - // Calculate line height to connect from origin circle bottom to destination icon top - // Origin circle center at: _inputFieldHeight / 2 = 24 - // Origin circle bottom at: 24 + 6 = 30 - // Destination icon center at: _inputFieldHeight + _fieldGap + _inputFieldHeight / 2 = 84 - // Destination icon top at: 84 - 8 = 76 - // Line height = 76 - 30 = 46 - const lineHeight = 46.0; + // Calculate dynamic heights for route indicator + final totalHeight = _originFieldHeight + _fieldGap + _destinationFieldHeight; + + // Line spans from center of origin field to center of destination field + // minus half of each indicator's size + final originCenter = _originFieldHeight / 2; + final destCenter = _originFieldHeight + _fieldGap + (_destinationFieldHeight / 2); + final lineTop = originCenter + (_originCircleSize / 2); + final lineBottom = destCenter - (_destinationIconSize / 2); + final lineHeight = (lineBottom - lineTop).clamp(0.0, double.infinity); return Card( elevation: 0, @@ -69,14 +111,14 @@ class LocationInputSection extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Route Indicator - fixed height column with calculated positions + // Route Indicator - dynamically positioned based on field heights SizedBox( - height: totalFieldsHeight, + height: totalHeight, child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ // Spacer to center origin circle with first field - SizedBox(height: (_inputFieldHeight - _originCircleSize) / 2), + SizedBox(height: (originCenter - (_originCircleSize / 2)).clamp(0.0, double.infinity)), // Origin circle indicator Container( width: _originCircleSize, @@ -86,7 +128,7 @@ class LocationInputSection extends StatelessWidget { shape: BoxShape.circle, ), ), - // Connecting line + // Connecting line - dynamically sized Container( width: 2, height: lineHeight, @@ -107,62 +149,46 @@ class LocationInputSection extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - SizedBox( - height: _inputFieldHeight, + // Origin field with key for measurement + Container( + key: _originFieldKey, child: _LocationField( label: 'Origin', - controller: originController, + controller: widget.originController, isOrigin: true, - isLoadingLocation: isLoadingLocation, + isLoadingLocation: widget.isLoadingLocation, onSearchLocations: (query) => - onSearchLocations(query, true), - onLocationSelected: onOriginSelected, - onUseCurrentLocation: onUseCurrentLocation, - onOpenMapPicker: () => onOpenMapPicker(true), + widget.onSearchLocations(query, true), + onLocationSelected: widget.onOriginSelected, + onUseCurrentLocation: widget.onUseCurrentLocation, + onOpenMapPicker: () => widget.onOpenMapPicker(true), ), ), const SizedBox(height: _fieldGap), - SizedBox( - height: _inputFieldHeight, + // Destination field with key for measurement + Container( + key: _destinationFieldKey, child: _LocationField( label: 'Destination', - controller: destinationController, + controller: widget.destinationController, isOrigin: false, isLoadingLocation: false, onSearchLocations: (query) => - onSearchLocations(query, false), - onLocationSelected: onDestinationSelected, + widget.onSearchLocations(query, false), + onLocationSelected: widget.onDestinationSelected, onUseCurrentLocation: null, - onOpenMapPicker: () => onOpenMapPicker(false), + onOpenMapPicker: () => widget.onOpenMapPicker(false), ), ), ], ), ), const SizedBox(width: 8), - // Swap Button - centered vertically relative to both input fields + // Swap Button - centered vertically in the total height SizedBox( - height: totalFieldsHeight, + height: totalHeight, child: Center( - child: Semantics( - label: 'Swap origin and destination', - button: true, - child: IconButton( - icon: Icon( - Icons.swap_vert_rounded, - color: colorScheme.primary, - ), - onPressed: onSwapLocations, - style: IconButton.styleFrom( - backgroundColor: colorScheme.primaryContainer.withValues( - alpha: 0.3, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), + child: _SwapButton(onSwap: widget.onSwapLocations), ), ), ], @@ -172,9 +198,40 @@ class LocationInputSection extends StatelessWidget { } } +/// Swap button extracted for cleaner code and proper semantics +class _SwapButton extends StatelessWidget { + final VoidCallback? onSwap; + + const _SwapButton({required this.onSwap}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Semantics( + label: 'Swap origin and destination', + button: true, + child: IconButton( + icon: Icon( + Icons.swap_vert_rounded, + color: colorScheme.primary, + ), + onPressed: onSwap, + style: IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer.withValues( + alpha: 0.3, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ); + } +} + /// Internal widget for individual location input field with autocomplete. -/// Now a StatefulWidget to track search loading state using ValueNotifier -/// to avoid interfering with Autocomplete's internal state. +/// Limited to 2 lines with internal scrolling for longer addresses. class _LocationField extends StatefulWidget { final String label; final TextEditingController controller; @@ -202,12 +259,42 @@ class _LocationField extends StatefulWidget { class _LocationFieldState extends State<_LocationField> { final ValueNotifier _isSearching = ValueNotifier(false); + double? _lastKnownWidth; + + /// Key used to force Autocomplete widget to rebuild when controller text changes externally + /// (e.g., during swap operations). Incremented when we detect external changes. + int _autocompleteRebuildKey = 0; + + /// Tracks the last known text to detect external changes + String _lastKnownText = ''; + + @override + void initState() { + super.initState(); + _lastKnownText = widget.controller.text; + widget.controller.addListener(_onControllerTextChanged); + } @override void dispose() { + widget.controller.removeListener(_onControllerTextChanged); _isSearching.dispose(); super.dispose(); } + + /// Listener that detects external text changes (e.g., from swap operation) + /// and forces the Autocomplete to rebuild with the new text. + void _onControllerTextChanged() { + if (widget.controller.text != _lastKnownText) { + _lastKnownText = widget.controller.text; + // Force Autocomplete to rebuild with new initialValue + if (mounted) { + setState(() { + _autocompleteRebuildKey++; + }); + } + } + } Future> _handleOptionsBuilder(String query) async { if (query.trim().isEmpty) { @@ -223,9 +310,10 @@ class _LocationFieldState extends State<_LocationField> { return results; } finally { // Delay clearing the loading state until after the current frame completes - // so the autocomplete options have time to render before the spinner disappears. WidgetsBinding.instance.addPostFrameCallback((_) { - _isSearching.value = false; + if (mounted) { + _isSearching.value = false; + } }); } } @@ -234,107 +322,109 @@ class _LocationFieldState extends State<_LocationField> { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + // Use LayoutBuilder to capture width for autocomplete options return LayoutBuilder( builder: (context, constraints) { + _lastKnownWidth = constraints.maxWidth; + return Autocomplete( + // Key changes when external text update detected, forcing rebuild + key: ValueKey('${widget.isOrigin}_$_autocompleteRebuildKey'), displayStringForOption: (Location option) => option.name, initialValue: TextEditingValue(text: widget.controller.text), optionsBuilder: (TextEditingValue textEditingValue) async { return _handleOptionsBuilder(textEditingValue.text); }, onSelected: widget.onLocationSelected, - fieldViewBuilder: - ( - BuildContext context, - TextEditingController textEditingController, - FocusNode focusNode, - VoidCallback onFieldSubmitted, - ) { - // Sync controller text - if (widget.isOrigin && - widget.controller.text != textEditingController.text) { - textEditingController.text = widget.controller.text; - } - return Semantics( - label: 'Input for ${widget.label} location', - textField: true, - child: ValueListenableBuilder( - valueListenable: _isSearching, - builder: (context, isSearching, child) { - return TextField( - controller: textEditingController, - focusNode: focusNode, - style: Theme.of(context).textTheme.bodyLarge, - decoration: InputDecoration( - hintText: widget.label, - filled: true, - fillColor: colorScheme.surfaceContainerLowest, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Show loading indicator when searching for suggestions - if (isSearching) - Padding( - padding: const EdgeInsets.all(12), - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: colorScheme.primary, - ), - ), - ) - // Show current location loading indicator (origin only) - else if (widget.isOrigin && - widget.isLoadingLocation) - const Padding( - padding: EdgeInsets.all(12), - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ), - ) - // Show current location button (origin only) - else if (widget.isOrigin && - widget.onUseCurrentLocation != null) - IconButton( - icon: Icon( - Icons.my_location, - color: colorScheme.primary, - size: 20, - ), - tooltip: 'Use my current location', - onPressed: widget.onUseCurrentLocation, + fieldViewBuilder: ( + BuildContext context, + TextEditingController textEditingController, + FocusNode focusNode, + VoidCallback onFieldSubmitted, + ) { + return Semantics( + label: 'Input for ${widget.label} location', + textField: true, + child: ValueListenableBuilder( + valueListenable: _isSearching, + builder: (context, isSearching, child) { + return TextField( + controller: textEditingController, + focusNode: focusNode, + style: Theme.of(context).textTheme.bodyLarge, + maxLines: 2, // Limit to 2 lines + minLines: 1, // Start with 1 line for short text + keyboardType: TextInputType.streetAddress, + decoration: InputDecoration( + hintText: widget.label, + filled: true, + fillColor: colorScheme.surfaceContainerLowest, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Show loading indicator when searching for suggestions + if (isSearching) + Padding( + padding: const EdgeInsets.all(12), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, ), - IconButton( - icon: Icon( - Icons.map_outlined, - color: colorScheme.onSurfaceVariant, - size: 20, + ), + ) + // Show current location loading indicator (origin only) + else if (widget.isOrigin && + widget.isLoadingLocation) + const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, ), - tooltip: 'Select from map', - onPressed: widget.onOpenMapPicker, ), - ], + ) + // Show current location button (origin only) + else if (widget.isOrigin && + widget.onUseCurrentLocation != null) + IconButton( + icon: Icon( + Icons.my_location, + color: colorScheme.primary, + size: 20, + ), + tooltip: 'Use my current location', + onPressed: widget.onUseCurrentLocation, + ), + IconButton( + icon: Icon( + Icons.map_outlined, + color: colorScheme.onSurfaceVariant, + size: 20, + ), + tooltip: 'Select from map', + onPressed: widget.onOpenMapPicker, ), - ), - ); - }, - ), - ); - }, + ], + ), + ), + ); + }, + ), + ); + }, optionsViewBuilder: (context, onSelected, options) { return Align( alignment: Alignment.topLeft, @@ -342,7 +432,7 @@ class _LocationFieldState extends State<_LocationField> { elevation: 4, borderRadius: BorderRadius.circular(12), child: Container( - width: constraints.maxWidth, + width: _lastKnownWidth ?? constraints.maxWidth, constraints: const BoxConstraints(maxHeight: 200), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, @@ -376,3 +466,4 @@ class _LocationFieldState extends State<_LocationField> { ); } } + diff --git a/lib/src/presentation/widgets/main_screen/transport_mode_selection_modal.dart b/lib/src/presentation/widgets/main_screen/transport_mode_selection_modal.dart index 138dc06..cad5f5b 100644 --- a/lib/src/presentation/widgets/main_screen/transport_mode_selection_modal.dart +++ b/lib/src/presentation/widgets/main_screen/transport_mode_selection_modal.dart @@ -72,16 +72,27 @@ class _TransportModeSelectionModalState } /// Loads saved transport mode preferences and initializes selected modes. - /// Modes that are NOT in the hidden set should be marked as selected. + /// For new users who haven't set preferences, use default enabled modes. + /// For existing users, modes that are NOT in the hidden set should be selected. Future _loadSavedPreferences() async { + final hasSetPrefs = await widget.settingsService.hasSetTransportModePreferences(); final hiddenModes = await widget.settingsService.getHiddenTransportModes(); setState(() { for (final formula in widget.availableFormulas) { final modeSubTypeKey = '${formula.mode}::${formula.subType}'; - // Mode is selected if it's NOT hidden - if (!hiddenModes.contains(modeSubTypeKey)) { - _selectedModes.add(modeSubTypeKey); + + if (!hasSetPrefs) { + // New user - use default enabled modes + final defaultModes = SettingsService.getDefaultEnabledModes(); + if (defaultModes.contains(modeSubTypeKey)) { + _selectedModes.add(modeSubTypeKey); + } + } else { + // Existing user - mode is selected if it's NOT hidden + if (!hiddenModes.contains(modeSubTypeKey)) { + _selectedModes.add(modeSubTypeKey); + } } } }); diff --git a/lib/src/presentation/widgets/main_screen/travel_options_bar.dart b/lib/src/presentation/widgets/main_screen/travel_options_bar.dart index 5e68a40..cbafa42 100644 --- a/lib/src/presentation/widgets/main_screen/travel_options_bar.dart +++ b/lib/src/presentation/widgets/main_screen/travel_options_bar.dart @@ -34,6 +34,22 @@ class TravelOptionsBar extends StatelessWidget { int get totalPassengers => regularPassengers + discountedPassengers; + /// Returns a user-friendly label for the sort criteria. + static String _getSortLabel(SortCriteria criteria) { + switch (criteria) { + case SortCriteria.priceAsc: + return 'Lowest'; + case SortCriteria.priceDesc: + return 'Highest'; + case SortCriteria.lowestOverall: + return 'Best Deal'; + case SortCriteria.durationAsc: + return 'Fastest'; + case SortCriteria.durationDesc: + return 'Slowest'; + } + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -133,34 +149,80 @@ class TravelOptionsBar extends StatelessWidget { ), ), const SizedBox(width: 8), - // Sort Chip + // Sort Chip - Popup Menu with all options Semantics( label: - 'Sort by: ${sortCriteria == SortCriteria.priceAsc ? 'Price Low to High' : 'Price High to Low'}', + 'Sort by: ${_getSortLabel(sortCriteria)}', button: true, - child: ActionChip( - avatar: Icon( - Icons.sort, - size: 18, - color: colorScheme.onSurfaceVariant, + child: PopupMenuButton( + onSelected: onSortChanged, + initialValue: sortCriteria, + position: PopupMenuPosition.under, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), - label: Text( - sortCriteria == SortCriteria.priceAsc ? 'Lowest' : 'Highest', - style: textTheme.labelLarge?.copyWith( - color: colorScheme.onSurface, + itemBuilder: (context) => [ + PopupMenuItem( + value: SortCriteria.priceAsc, + child: Row( + children: [ + Icon( + Icons.arrow_upward_rounded, + size: 18, + color: colorScheme.onSurface, + ), + const SizedBox(width: 8), + const Text('Lowest Price'), + ], + ), + ), + PopupMenuItem( + value: SortCriteria.priceDesc, + child: Row( + children: [ + Icon( + Icons.arrow_downward_rounded, + size: 18, + color: colorScheme.onSurface, + ), + const SizedBox(width: 8), + const Text('Highest Price'), + ], + ), + ), + PopupMenuItem( + value: SortCriteria.lowestOverall, + child: Row( + children: [ + Icon( + Icons.star_outline_rounded, + size: 18, + color: colorScheme.primary, + ), + const SizedBox(width: 8), + const Text('Lowest Overall'), + ], + ), + ), + ], + child: Chip( + avatar: Icon( + Icons.sort, + size: 18, + color: colorScheme.onSurfaceVariant, + ), + label: Text( + _getSortLabel(sortCriteria), + style: textTheme.labelLarge?.copyWith( + color: colorScheme.onSurface, + ), + ), + backgroundColor: colorScheme.surfaceContainerLowest, + side: BorderSide(color: colorScheme.outlineVariant), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), ), ), - backgroundColor: colorScheme.surfaceContainerLowest, - side: BorderSide(color: colorScheme.outlineVariant), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - onPressed: () { - final newCriteria = sortCriteria == SortCriteria.priceAsc - ? SortCriteria.priceDesc - : SortCriteria.priceAsc; - onSortChanged(newCriteria); - }, ), ), ], diff --git a/lib/src/services/fare_comparison_service.dart b/lib/src/services/fare_comparison_service.dart index 4ff52f4..ac7bfb4 100644 --- a/lib/src/services/fare_comparison_service.dart +++ b/lib/src/services/fare_comparison_service.dart @@ -4,7 +4,18 @@ import '../models/fare_result.dart'; import '../core/constants/region_constants.dart'; import 'transport_mode_filter_service.dart'; -enum SortCriteria { priceAsc, priceDesc, durationAsc, durationDesc } +enum SortCriteria { + /// Sort by price ascending (lowest first) - grouped by mode + priceAsc, + /// Sort by price descending (highest first) - grouped by mode + priceDesc, + /// Sort by duration ascending (fastest first) + durationAsc, + /// Sort by duration descending (slowest first) + durationDesc, + /// Sort ALL fares by lowest price regardless of mode categorization + lowestOverall, +} @lazySingleton class FareComparisonService { @@ -117,6 +128,11 @@ class FareComparisonService { throw UnimplementedError( 'Duration sorting requires duration data in FareResult', ); + case SortCriteria.lowestOverall: + // Sort ALL results by lowest fare, ignoring mode categorization + // This flattens all results and sorts purely by price + sortedResults.sort((a, b) => a.totalFare.compareTo(b.totalFare)); + break; } return sortedResults; diff --git a/lib/src/services/settings_service.dart b/lib/src/services/settings_service.dart index bba687f..61bd7df 100644 --- a/lib/src/services/settings_service.dart +++ b/lib/src/services/settings_service.dart @@ -152,6 +152,20 @@ class SettingsService { return prefs.getBool(_keyHasSetTransportModePreferences) ?? false; } + /// Get the default enabled transport modes for new users. + /// Returns mode-subtype keys in format "Mode::SubType". + /// Default modes: Jeepney (Traditional, Modern), Bus (Traditional, Aircon), Taxi (White Regular). + /// These keys must match exactly with the sub_type values in fare_formulas.json. + static Set getDefaultEnabledModes() { + return { + 'Jeepney::Traditional', + 'Jeepney::Modern (PUJ)', + 'Bus::Traditional', + 'Bus::Aircon', + 'Taxi::White (Regular)', + }; + } + /// Get the list of hidden transport modes (stored as "Mode::SubType" strings). /// NOTE: For new users who haven't set preferences yet, this returns an empty set. /// The caller should use hasSetTransportModePreferences() to check if all modes @@ -181,15 +195,20 @@ class SettingsService { } /// Check if a specific mode-subtype combination is hidden. - /// Takes into account whether user has set preferences (new users = all hidden). + /// Takes into account whether user has set preferences. + /// For new users who haven't set preferences, only default modes are enabled. Future isTransportModeHidden(String mode, String subType) async { final hasSetPrefs = await hasSetTransportModePreferences(); - // For new users who haven't set any preferences, all modes are hidden by default + final modeKey = '$mode::$subType'; + + // For new users who haven't set any preferences, use default enabled modes if (!hasSetPrefs) { - return true; + final defaultModes = getDefaultEnabledModes(); + // If the mode is in the default enabled set, it's NOT hidden + return !defaultModes.contains(modeKey); } final hiddenModes = await getHiddenTransportModes(); - return hiddenModes.contains('$mode::$subType'); + return hiddenModes.contains(modeKey); } /// Get the list of enabled transport modes (opposite of hidden modes) diff --git a/pubspec.yaml b/pubspec.yaml index ae2b328..5a5b52d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and build number is used as the build suffix. -version: 2.1.0+3 +version: 2.2.0+4 environment: sdk: ^3.9.2 diff --git a/test/models/map_region_test.dart b/test/models/map_region_test.dart index 70f221f..bcf33c8 100644 --- a/test/models/map_region_test.dart +++ b/test/models/map_region_test.dart @@ -609,9 +609,12 @@ void main() { }); test('usedPercentage returns correct value', () { - final usedBytes = 34359738368 - 5368709120; // total - available - final expectedPercentage = usedBytes / 34359738368; - expect(storageInfo.usedPercentage, closeTo(expectedPercentage, 0.01)); + // usedPercentage should represent map cache as a ratio of (cache + available space) + // mapCacheBytes = 52428800 (50 MB), availableBytes = 5368709120 (5 GB) + // Expected: 50MB / (50MB + 5GB) β‰ˆ 0.00967 + final totalUsableSpace = storageInfo.mapCacheBytes + storageInfo.availableBytes; + final expectedPercentage = storageInfo.mapCacheBytes / totalUsableSpace; + expect(storageInfo.usedPercentage, closeTo(expectedPercentage, 0.0001)); }); test('mapCacheFormatted returns MB format for large sizes', () { @@ -639,7 +642,37 @@ void main() { availableBytes: 0, totalBytes: 0, ); + // When both mapCacheBytes and availableBytes are 0, usedPercentage should be 0 expect(zeroStorage.usedPercentage, 0.0); }); + + test('usedPercentage shows correct bar percentage for storage display', () { + // Test case: 0 cached, 10GB free -> bar should be nearly empty (0%) + final emptyCache = StorageInfo( + appStorageBytes: 0, + mapCacheBytes: 0, + availableBytes: 1073741824 * 10, // 10 GB + totalBytes: 1073741824 * 32, // 32 GB + ); + expect(emptyCache.usedPercentage, 0.0); + + // Test case: 5GB cached, 5GB free -> bar should be 50% + final halfFull = StorageInfo( + appStorageBytes: 0, + mapCacheBytes: 1073741824 * 5, // 5 GB + availableBytes: 1073741824 * 5, // 5 GB + totalBytes: 1073741824 * 32, // 32 GB + ); + expect(halfFull.usedPercentage, closeTo(0.5, 0.01)); + + // Test case: lots cached, little free -> bar should be mostly filled + final mostlyFull = StorageInfo( + appStorageBytes: 0, + mapCacheBytes: 1073741824 * 9, // 9 GB + availableBytes: 1073741824 * 1, // 1 GB + totalBytes: 1073741824 * 32, // 32 GB + ); + expect(mostlyFull.usedPercentage, closeTo(0.9, 0.01)); + }); }); } diff --git a/test/screens/region_download_screen_test.dart b/test/screens/region_download_screen_test.dart index 4e3fb43..c9ee794 100644 --- a/test/screens/region_download_screen_test.dart +++ b/test/screens/region_download_screen_test.dart @@ -402,16 +402,30 @@ void main() { }); test('storage progress bar value is correct', () { + // usedPercentage represents map cache as a ratio of total usable space + // mapCacheBytes / (mapCacheBytes + availableBytes) const info = StorageInfo( appStorageBytes: 0, - mapCacheBytes: 0, + mapCacheBytes: 1073741824 * 2, // 2 GB cached availableBytes: 1073741824 * 6, // 6 GB available - totalBytes: 1073741824 * 8, // 8 GB total + totalBytes: 1073741824 * 8, // 8 GB total device ); - // 2 GB used out of 8 GB = 25% + // 2 GB cache out of (2 GB cache + 6 GB available) = 2/8 = 25% expect(info.usedPercentage, closeTo(0.25, 0.01)); }); + + test('storage progress bar empty when no cache', () { + const info = StorageInfo( + appStorageBytes: 0, + mapCacheBytes: 0, // 0 cached + availableBytes: 1073741824 * 6, // 6 GB available + totalBytes: 1073741824 * 8, // 8 GB total + ); + + // 0 cache means bar should show 0% + expect(info.usedPercentage, 0.0); + }); }); group('Action button states', () {