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/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/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 ef9be17..1404a77 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 @@ -204,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; @@ -217,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) { @@ -297,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(); @@ -338,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 d5bd5b3..ce14701 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,42 @@ class _MainScreenState extends State { }); } + /// Loads transport mode counts for the quick-access button badge. + Future _loadTransportModeCounts() async { + try { + final allFormulas = await _fareRepository.getAllFormulas(); + final hasSetPrefs = await _settingsService.hasSetTransportModePreferences(); + final hiddenModes = await _settingsService.getHiddenTransportModes(); + + // Count unique mode-subtype combinations + final allModeKeys = {}; + for (final formula in allFormulas) { + allModeKeys.add('${formula.mode}::${formula.subType}'); + } + + 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(() { + _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 +206,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 +226,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 +349,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..5148371 100644 --- a/lib/src/presentation/screens/map_picker_screen.dart +++ b/lib/src/presentation/screens/map_picker_screen.dart @@ -1,8 +1,13 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; 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 +34,19 @@ 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); + final ValueNotifier _isLoadingAddress = ValueNotifier(false); String _addressText = 'Move map to select location'; - final TextEditingController _searchController = TextEditingController(); - bool _isSearching = false; + + /// 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; @@ -47,6 +60,7 @@ class _MapPickerScreenState extends State void initState() { super.initState(); _mapController = MapController(); + _geocodingService = getIt(); _selectedLocation = widget.initialLocation ?? _defaultCenter; // Initialize pin animation controller @@ -79,8 +93,10 @@ class _MapPickerScreenState extends State @override void dispose() { + _geocodeDebounceTimer?.cancel(); _pinAnimationController.dispose(); - _searchController.dispose(); + _isSearchingLocation.dispose(); + _isLoadingAddress.dispose(); super.dispose(); } @@ -88,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(() { @@ -103,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); @@ -128,11 +179,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 +444,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), + ); + }, + ), + ), + ), + ); + }, + ); + }, ), - ), + ], ), ), ); @@ -496,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/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/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 33cecdc..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,8 +198,41 @@ 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. -class _LocationField extends StatelessWidget { +/// Limited to 2 lines with internal scrolling for longer addresses. +class _LocationField extends StatefulWidget { final String label; final TextEditingController controller; final bool isOrigin; @@ -194,42 +253,110 @@ class _LocationField extends StatelessWidget { required this.onOpenMapPicker, }); + @override + State<_LocationField> createState() => _LocationFieldState(); +} + +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) { + _isSearching.value = false; + return const []; + } + + // Set loading state before fetching + _isSearching.value = true; + + try { + final results = await widget.onSearchLocations(query); + return results; + } finally { + // Delay clearing the loading state until after the current frame completes + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _isSearching.value = false; + } + }); + } + } + @override 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: 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, - fieldViewBuilder: - ( - BuildContext context, - TextEditingController textEditingController, - FocusNode focusNode, - VoidCallback onFieldSubmitted, - ) { - // Sync controller text - if (isOrigin && controller.text != textEditingController.text) { - textEditingController.text = controller.text; - } - return Semantics( - label: 'Input for $label location', - textField: true, - child: TextField( + onSelected: widget.onLocationSelected, + 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: label, + hintText: widget.label, filled: true, fillColor: colorScheme.surfaceContainerLowest, contentPadding: const EdgeInsets.symmetric( @@ -243,7 +370,22 @@ class _LocationField extends StatelessWidget { suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ - if (isOrigin && isLoadingLocation) + // 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( @@ -254,7 +396,9 @@ class _LocationField extends StatelessWidget { ), ), ) - else if (isOrigin && onUseCurrentLocation != null) + // Show current location button (origin only) + else if (widget.isOrigin && + widget.onUseCurrentLocation != null) IconButton( icon: Icon( Icons.my_location, @@ -262,7 +406,7 @@ class _LocationField extends StatelessWidget { size: 20, ), tooltip: 'Use my current location', - onPressed: onUseCurrentLocation, + onPressed: widget.onUseCurrentLocation, ), IconButton( icon: Icon( @@ -271,14 +415,16 @@ class _LocationField extends StatelessWidget { size: 20, ), tooltip: 'Select from map', - onPressed: onOpenMapPicker, + onPressed: widget.onOpenMapPicker, ), ], ), ), - ), - ); - }, + ); + }, + ), + ); + }, optionsViewBuilder: (context, onSelected, options) { return Align( alignment: Alignment.topLeft, @@ -286,7 +432,7 @@ class _LocationField extends StatelessWidget { 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, @@ -320,3 +466,4 @@ class _LocationField extends StatelessWidget { ); } } + 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..cad5f5b --- /dev/null +++ b/lib/src/presentation/widgets/main_screen/transport_mode_selection_modal.dart @@ -0,0 +1,563 @@ +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(); + _loadSavedPreferences(); + } + + /// Loads saved transport mode preferences and initializes selected modes. + /// 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}'; + + 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); + } + } + } + }); + } + + 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..cbafa42 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,10 +27,29 @@ 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; + /// 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; @@ -77,34 +105,124 @@ class TravelOptionsBar extends StatelessWidget { ), ), const SizedBox(width: 8), - // Sort Chip + // 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 - 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 5ed51a4..61bd7df 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,31 @@ 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 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 + /// should be treated as hidden (disabled) by default. Future> getHiddenTransportModes() async { final prefs = await SharedPreferences.getInstance(); final hiddenList = prefs.getStringList(_keyHiddenTransportModes); @@ -164,12 +190,25 @@ 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. + /// For new users who haven't set preferences, only default modes are enabled. Future isTransportModeHidden(String mode, String subType) async { + final hasSetPrefs = await hasSetTransportModePreferences(); + final modeKey = '$mode::$subType'; + + // For new users who haven't set any preferences, use default enabled modes + if (!hasSetPrefs) { + 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/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/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/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; 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', () {