diff --git a/README.md b/README.md index cd066a8..98a2bfa 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Flutter](https://img.shields.io/badge/Built%20with-Flutter-blue.svg)](https://flutter.dev/) [![CI](https://github.com/MasuRii/ph-fare-calculator/actions/workflows/ci.yml/badge.svg)](https://github.com/MasuRii/ph-fare-calculator/actions/workflows/ci.yml) -[![Version](https://img.shields.io/badge/version-2.0.0-blue.svg)](https://github.com/MasuRii/ph-fare-calculator/releases) +[![Version](https://img.shields.io/badge/version-2.1.0-blue.svg)](https://github.com/MasuRii/ph-fare-calculator/releases) **PH Fare Calculator** is a cross-platform mobile application designed to help tourists, expats, and locals estimate public transport costs across the Philippines. diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4..2d6795f 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b7..7179e13 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391..2b8ea5a 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d..5d12436 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372e..2ae7566 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/icons/PHFareCalculatorLogo/icon-1024x1024.png b/assets/icons/PHFareCalculatorLogo/icon-1024x1024.png new file mode 100644 index 0000000..241ae9d Binary files /dev/null and b/assets/icons/PHFareCalculatorLogo/icon-1024x1024.png differ diff --git a/assets/icons/PHFareCalculatorLogo/icon-144x144.png b/assets/icons/PHFareCalculatorLogo/icon-144x144.png new file mode 100644 index 0000000..5d12436 Binary files /dev/null and b/assets/icons/PHFareCalculatorLogo/icon-144x144.png differ diff --git a/assets/icons/PHFareCalculatorLogo/icon-192x192.png b/assets/icons/PHFareCalculatorLogo/icon-192x192.png new file mode 100644 index 0000000..2ae7566 Binary files /dev/null and b/assets/icons/PHFareCalculatorLogo/icon-192x192.png differ diff --git a/assets/icons/PHFareCalculatorLogo/icon-20x20.png b/assets/icons/PHFareCalculatorLogo/icon-20x20.png new file mode 100644 index 0000000..65686d7 Binary files /dev/null and b/assets/icons/PHFareCalculatorLogo/icon-20x20.png differ diff --git a/assets/icons/PHFareCalculatorLogo/icon-29x29.png b/assets/icons/PHFareCalculatorLogo/icon-29x29.png new file mode 100644 index 0000000..0ea5e3d Binary files /dev/null and b/assets/icons/PHFareCalculatorLogo/icon-29x29.png differ diff --git a/assets/icons/PHFareCalculatorLogo/icon-36x36.png b/assets/icons/PHFareCalculatorLogo/icon-36x36.png new file mode 100644 index 0000000..05ff77d Binary files /dev/null and b/assets/icons/PHFareCalculatorLogo/icon-36x36.png differ diff --git a/assets/icons/PHFareCalculatorLogo/icon-40x40.png b/assets/icons/PHFareCalculatorLogo/icon-40x40.png new file mode 100644 index 0000000..80d6233 Binary files /dev/null and b/assets/icons/PHFareCalculatorLogo/icon-40x40.png differ diff --git a/assets/icons/PHFareCalculatorLogo/icon-48x48.png b/assets/icons/PHFareCalculatorLogo/icon-48x48.png new file mode 100644 index 0000000..7179e13 Binary files /dev/null and b/assets/icons/PHFareCalculatorLogo/icon-48x48.png differ diff --git a/assets/icons/PHFareCalculatorLogo/icon-512x512.png b/assets/icons/PHFareCalculatorLogo/icon-512x512.png new file mode 100644 index 0000000..07fa916 Binary files /dev/null and b/assets/icons/PHFareCalculatorLogo/icon-512x512.png differ diff --git a/assets/icons/PHFareCalculatorLogo/icon-60x60.png b/assets/icons/PHFareCalculatorLogo/icon-60x60.png new file mode 100644 index 0000000..8ea4893 Binary files /dev/null and b/assets/icons/PHFareCalculatorLogo/icon-60x60.png differ diff --git a/assets/icons/PHFareCalculatorLogo/icon-72x72.png b/assets/icons/PHFareCalculatorLogo/icon-72x72.png new file mode 100644 index 0000000..2d6795f Binary files /dev/null and b/assets/icons/PHFareCalculatorLogo/icon-72x72.png differ diff --git a/assets/icons/PHFareCalculatorLogo/icon-76x76.png b/assets/icons/PHFareCalculatorLogo/icon-76x76.png new file mode 100644 index 0000000..25ea760 Binary files /dev/null and b/assets/icons/PHFareCalculatorLogo/icon-76x76.png differ diff --git a/assets/icons/PHFareCalculatorLogo/icon-84x84.png b/assets/icons/PHFareCalculatorLogo/icon-84x84.png new file mode 100644 index 0000000..32d68c8 Binary files /dev/null and b/assets/icons/PHFareCalculatorLogo/icon-84x84.png differ diff --git a/assets/icons/PHFareCalculatorLogo/icon-96x96.png b/assets/icons/PHFareCalculatorLogo/icon-96x96.png new file mode 100644 index 0000000..2b8ea5a Binary files /dev/null and b/assets/icons/PHFareCalculatorLogo/icon-96x96.png differ diff --git a/assets/icons/README.md b/assets/icons/README.md new file mode 100644 index 0000000..6a6aaec --- /dev/null +++ b/assets/icons/README.md @@ -0,0 +1,115 @@ +# App Launcher Icon Setup + +This directory contains the source icons for generating the app launcher icons across Android and iOS platforms. + +## Required Files + +You need to provide the following icon files: + +### 1. `app_icon.png` (Required) +- **Dimensions**: 1024x1024 pixels +- **Format**: PNG +- **Purpose**: Main app icon for iOS and fallback for Android +- **Design Requirements**: + - Use a **bus icon** (Material Icons `directions_bus`) as the central element + - **Background color**: Philippine flag blue (#0038A8) + - **Icon color**: White + - **No transparency** (alpha channel will be removed for iOS) + - The icon should match the `AppLogoWidget` design in the app + +### 2. `app_icon_foreground.png` (Required for Android Adaptive Icons) +- **Dimensions**: 1024x1024 pixels (with safe zone) +- **Format**: PNG with transparency +- **Purpose**: Foreground layer for Android adaptive icons +- **Design Requirements**: + - **White bus icon** centered on a **transparent background** + - Keep the icon within the safe zone (centered 66% of the image) + - This allows Android to apply various shapes (circle, squircle, etc.) + +## Color Scheme + +| Element | Color | Hex Code | +|---------|-------|----------| +| Background | Philippine Flag Blue | #0038A8 | +| Icon (Bus) | White | #FFFFFF | + +## Generating Icons + +Once you have placed both PNG files in this directory, run: + +```bash +flutter pub run flutter_launcher_icons +``` + +Or on newer Flutter versions: + +```bash +dart run flutter_launcher_icons +``` + +## What Gets Generated + +The command will generate: + +### Android +- `android/app/src/main/res/mipmap-hdpi/ic_launcher.png` (72x72) +- `android/app/src/main/res/mipmap-mdpi/ic_launcher.png` (48x48) +- `android/app/src/main/res/mipmap-xhdpi/ic_launcher.png` (96x96) +- `android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png` (144x144) +- `android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png` (192x192) +- Adaptive icon resources in `mipmap-anydpi-v26/` + +### iOS +- All required sizes in `ios/Runner/Assets.xcassets/AppIcon.appiconset/` +- Sizes: 20, 29, 40, 60, 76, 83.5, 1024 (with @1x, @2x, @3x variants) + +## Creating the Icon Images + +Since Flutter cannot render widgets to PNG files programmatically at build time, you need to create the icon images manually using one of these methods: + +### Option 1: Design Software +Use Figma, Sketch, Adobe XD, or similar to create the icons: +1. Create a 1024x1024 canvas +2. Fill background with #0038A8 +3. Add a white bus icon (centered, about 60% of canvas size) +4. Export as PNG + +### Option 2: Online Tools +Use icon generators like: +- [App Icon Generator](https://appicon.co/) +- [MakeAppIcon](https://makeappicon.com/) + +### Option 3: Screenshot from App +1. Run the app and navigate to a screen showing the `AppLogoWidget` +2. Take a screenshot of the logo +3. Edit to 1024x1024 with square aspect ratio + +## Configuration Reference + +The `pubspec.yaml` contains the following configuration: + +```yaml +flutter_launcher_icons: + android: true + ios: true + image_path: "assets/icons/app_icon.png" + remove_alpha_ios: true + adaptive_icon_background: "#0038A8" + adaptive_icon_foreground: "assets/icons/app_icon_foreground.png" + min_sdk_android: 21 +``` + +## Troubleshooting + +### "Image not found" error +Ensure both `app_icon.png` and `app_icon_foreground.png` exist in this directory. + +### iOS icon has white corners +This is expected. iOS automatically applies rounded corners to app icons. + +### Android icon looks different on different devices +Android adaptive icons allow manufacturers to apply different shapes. The `adaptive_icon_foreground.png` with transparent background ensures your icon looks good in all shapes. + +## Related Files + +- [`lib/src/presentation/widgets/app_logo_widget.dart`](../../lib/src/presentation/widgets/app_logo_widget.dart) - The in-app logo widget that the icon design should match \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 36826c1..263a5f0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,10 +20,10 @@ Future main() async { // Pre-initialize static notifiers from SharedPreferences to avoid race condition // This ensures ValueListenableBuilders have correct values when the widget tree is built final prefs = await SharedPreferences.getInstance(); - final isHighContrast = prefs.getBool('isHighContrastEnabled') ?? false; + final themeMode = prefs.getString('themeMode') ?? 'light'; final languageCode = prefs.getString('locale') ?? 'en'; - SettingsService.highContrastNotifier.value = isHighContrast; + SettingsService.themeModeNotifier.value = themeMode; SettingsService.localeNotifier.value = Locale(languageCode); runApp(const MyApp()); @@ -32,17 +32,32 @@ Future main() async { class MyApp extends StatelessWidget { const MyApp({super.key}); + /// Convert theme mode string to ThemeMode enum + ThemeMode _getThemeMode(String mode) { + switch (mode) { + case 'light': + return ThemeMode.light; + case 'dark': + return ThemeMode.dark; + case 'system': + default: + return ThemeMode.system; + } + } + @override Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: SettingsService.highContrastNotifier, - builder: (context, isHighContrast, child) { + return ValueListenableBuilder( + valueListenable: SettingsService.themeModeNotifier, + builder: (context, themeMode, child) { return ValueListenableBuilder( valueListenable: SettingsService.localeNotifier, builder: (context, locale, child) { return MaterialApp( title: 'PH Fare Calculator', - theme: isHighContrast ? AppTheme.darkTheme : AppTheme.lightTheme, + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: _getThemeMode(themeMode), locale: locale, localizationsDelegates: const [ AppLocalizations.delegate, diff --git a/lib/src/core/constants/app_constants.dart b/lib/src/core/constants/app_constants.dart index ff6ceed..38e3ff0 100644 --- a/lib/src/core/constants/app_constants.dart +++ b/lib/src/core/constants/app_constants.dart @@ -2,4 +2,8 @@ class AppConstants { /// Default OSRM public server URL. static const String kOsrmBaseUrl = 'http://router.project-osrm.org'; + + /// GitHub repository URL. + static const String repositoryUrl = + 'https://github.com/MasuRii/PH-Fare-Calculator'; } diff --git a/lib/src/core/theme/app_theme.dart b/lib/src/core/theme/app_theme.dart index bdd9229..5493742 100644 --- a/lib/src/core/theme/app_theme.dart +++ b/lib/src/core/theme/app_theme.dart @@ -1,28 +1,122 @@ import 'package:flutter/material.dart'; -// import 'package:google_fonts/google_fonts.dart'; // Uncomment when dependency added -/// Application theme configuration with Jeepney-inspired color palette. -/// Based on Philippine flag colors: Blue, Yellow, Red. +import 'transit_colors.dart'; + +/// Application theme configuration with Archipelago Blue color palette. +/// Deep teal primary paired with energetic orange accents, +/// inspired by the Philippine seas and islands. class AppTheme { // Private constructor to prevent instantiation AppTheme._(); - // Brand Colors - static const Color _seedColor = Color(0xFF0038A8); // PH Blue - static const Color _secondaryColor = Color(0xFFFCD116); // PH Yellow - static const Color _tertiaryColor = Color(0xFFCE1126); // PH Red + // ============================================ + // LIGHT MODE - Archipelago Blue Theme + // Optimized for outdoor visibility under strong tropical sunlight + // ============================================ + + // Primary Colors (Deep Teal) + static const Color _lightPrimary = Color(0xFF006064); // Deep Teal + static const Color _lightOnPrimary = Color(0xFFFFFFFF); + static const Color _lightPrimaryContainer = Color(0xFFE0F7FA); // Coastal Foam + static const Color _lightOnPrimaryContainer = Color( + 0xFF001F23, + ); // Deepest Teal + + // Secondary Colors (Pacific Blue) + static const Color _lightSecondary = Color(0xFF0097A7); // Pacific Blue + static const Color _lightOnSecondary = Color(0xFFFFFFFF); + static const Color _lightSecondaryContainer = Color(0xFFD6F7FC); // Reef Mist + static const Color _lightOnSecondaryContainer = Color( + 0xFF001F25, + ); // Deep Reef + + // Tertiary Colors (Lifevest Orange) + static const Color _lightTertiary = Color(0xFFFF6F00); // Lifevest Orange + static const Color _lightOnTertiary = Color(0xFF210A00); // Deep Brown + static const Color _lightTertiaryContainer = Color(0xFFFFDCC2); // Sunset Glow + static const Color _lightOnTertiaryContainer = Color( + 0xFF3E1800, + ); // Burnt Orange + + // Error Colors + static const Color _lightError = Color(0xFFBA1A1A); + static const Color _lightOnError = Color(0xFFFFFFFF); + + // Surface & Background Colors + static const Color _lightSurface = Color(0xFFFFFFFF); + static const Color _lightOnSurface = Color(0xFF191C1C); // Ink Grey + static const Color _lightSurfaceVariant = Color( + 0xFFDAE4E5, + ); // Neutral Variant + static const Color _lightOnSurfaceVariant = Color(0xFF3F4949); // Text Variant + static const Color _lightOutline = Color(0xFF6F7979); // Outline Grey + static const Color _lightBackground = Color(0xFFF5FDFE); // Mist + + // ============================================ + // DARK MODE - Archipelago Blue Theme + // Optimized for OLED displays and night commuting comfort + // ============================================ + + // Primary Colors (Cyan Aqua - pastel for dark mode) + static const Color _darkPrimary = Color(0xFF4DD0E1); // Cyan Aqua + static const Color _darkOnPrimary = Color(0xFF00363A); // Deep Teal + static const Color _darkPrimaryContainer = Color(0xFF004F52); // Teal Depth + static const Color _darkOnPrimaryContainer = Color(0xFFE0F7FA); // Cyan Light + + // Secondary Colors (Reef Blue) + static const Color _darkSecondary = Color(0xFF80DEEA); // Reef Blue + static const Color _darkOnSecondary = Color(0xFF00363D); // Deep Reef + static const Color _darkSecondaryContainer = Color( + 0xFF004F58, + ); // Pacific Depth + static const Color _darkOnSecondaryContainer = Color(0xFFD6F7FC); // Reef Mist + + // Tertiary Colors (Coral) + static const Color _darkTertiary = Color(0xFFFFB74D); // Coral + static const Color _darkOnTertiary = Color(0xFF452300); // Deep Brown + static const Color _darkTertiaryContainer = Color(0xFF633300); // Orange Depth + static const Color _darkOnTertiaryContainer = Color(0xFFFFDCC2); // Peach + + // Error Colors + static const Color _darkError = Color(0xFFFFB4AB); // Soft Error + static const Color _darkOnError = Color(0xFF690005); // Dark Red + + // Surface & Background Colors + static const Color _darkSurface = Color(0xFF001F25); // Deep Sea + static const Color _darkOnSurface = Color(0xFFE0E3E3); // Soft White + static const Color _darkSurfaceVariant = Color(0xFF3F4949); // Dark Metal + static const Color _darkOnSurfaceVariant = Color(0xFFBEC8C9); // Metal Text + static const Color _darkOutline = Color(0xFF899393); // Soft Outline + static const Color _darkBackground = Color( + 0xFF001216, + ); // Abyss (OLED friendly) /// Light theme for the application. static ThemeData get lightTheme { return ThemeData( useMaterial3: true, - colorScheme: ColorScheme.fromSeed( - seedColor: _seedColor, - secondary: _secondaryColor, - tertiary: _tertiaryColor, - brightness: Brightness.light, - surface: const Color(0xFFFFFFFF), - surfaceContainerLowest: const Color(0xFFF8F9FA), // Background + colorScheme: const ColorScheme.light( + primary: _lightPrimary, + onPrimary: _lightOnPrimary, + primaryContainer: _lightPrimaryContainer, + onPrimaryContainer: _lightOnPrimaryContainer, + secondary: _lightSecondary, + onSecondary: _lightOnSecondary, + secondaryContainer: _lightSecondaryContainer, + onSecondaryContainer: _lightOnSecondaryContainer, + tertiary: _lightTertiary, + onTertiary: _lightOnTertiary, + tertiaryContainer: _lightTertiaryContainer, + onTertiaryContainer: _lightOnTertiaryContainer, + error: _lightError, + onError: _lightOnError, + surface: _lightSurface, + onSurface: _lightOnSurface, + surfaceContainerLowest: _lightBackground, // App background + surfaceContainerHighest: _lightSurfaceVariant, + onSurfaceVariant: _lightOnSurfaceVariant, + outline: _lightOutline, + outlineVariant: _lightSurfaceVariant, ), // Typography @@ -73,7 +167,7 @@ class AppTheme { 0, // Flat by default for modern look, outline handles separation shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: const BorderSide(color: Color(0xFFE0E0E0), width: 1), + side: const BorderSide(color: _lightSurfaceVariant, width: 1), ), margin: EdgeInsets.zero, ), @@ -95,19 +189,19 @@ class AppTheme { ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: _seedColor, width: 2), + borderSide: const BorderSide(color: _lightPrimary, width: 2), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: _tertiaryColor, width: 1), + borderSide: const BorderSide(color: _lightError, width: 1), ), labelStyle: const TextStyle(color: Color(0xFF757575)), ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( - backgroundColor: _seedColor, - foregroundColor: Colors.white, + backgroundColor: _lightPrimary, + foregroundColor: _lightOnPrimary, elevation: 0, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), shape: const StadiumBorder(), @@ -117,10 +211,10 @@ class AppTheme { outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( - foregroundColor: _seedColor, + foregroundColor: _lightPrimary, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), shape: const StadiumBorder(), - side: const BorderSide(color: _seedColor), + side: const BorderSide(color: _lightPrimary), textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), ), ), @@ -130,58 +224,121 @@ class AppTheme { elevation: 0, centerTitle: false, titleTextStyle: TextStyle( - color: Color(0xFF1A1C1E), + color: _lightOnSurface, fontSize: 22, fontWeight: FontWeight.bold, ), - iconTheme: IconThemeData(color: Color(0xFF1A1C1E)), + iconTheme: IconThemeData(color: _lightOnSurface), ), navigationBarTheme: NavigationBarThemeData( elevation: 0, - backgroundColor: const Color(0xFFF0F4FF), // Very light blue tint - indicatorColor: _seedColor.withValues(alpha: 0.2), + backgroundColor: _lightPrimaryContainer, + indicatorColor: _lightPrimary.withValues(alpha: 0.2), labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, iconTheme: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.selected)) { - return const IconThemeData(color: _seedColor); + return const IconThemeData(color: _lightPrimary); } return const IconThemeData(color: Color(0xFF757575)); }), ), + + // Theme Extensions + extensions: const >[TransitColors.light], ); } - /// Dark theme (High Contrast Friendly) for the application. + /// Dark theme for the application. + /// Optimized for OLED displays and night commuting comfort. static ThemeData get darkTheme { return ThemeData( useMaterial3: true, brightness: Brightness.dark, - scaffoldBackgroundColor: const Color(0xFF121212), + scaffoldBackgroundColor: _darkBackground, colorScheme: const ColorScheme.dark( - primary: Color(0xFFB3C5FF), // Pastel Blue - onPrimary: Color(0xFF002A78), - secondary: Color(0xFFFDE26C), // Pastel Yellow - onSecondary: Color(0xFF3B2F00), - tertiary: Color(0xFFFFB4AB), // Pastel Red - error: Color(0xFFCF6679), - surface: Color(0xFF1E1E1E), - onSurface: Color(0xFFE2E2E2), - surfaceContainerLowest: Color(0xFF121212), - ), - - // Reuse typography styles but ensure colors adapt automatically via Theme.of(context) + primary: _darkPrimary, + onPrimary: _darkOnPrimary, + primaryContainer: _darkPrimaryContainer, + onPrimaryContainer: _darkOnPrimaryContainer, + secondary: _darkSecondary, + onSecondary: _darkOnSecondary, + secondaryContainer: _darkSecondaryContainer, + onSecondaryContainer: _darkOnSecondaryContainer, + tertiary: _darkTertiary, + onTertiary: _darkOnTertiary, + tertiaryContainer: _darkTertiaryContainer, + onTertiaryContainer: _darkOnTertiaryContainer, + error: _darkError, + onError: _darkOnError, + surface: _darkSurface, + onSurface: _darkOnSurface, + surfaceContainerLowest: _darkBackground, + surfaceContainerLow: Color(0xFF001A1F), + surfaceContainer: _darkSurface, + surfaceContainerHigh: Color(0xFF002A32), + surfaceContainerHighest: Color(0xFF003640), + onSurfaceVariant: _darkOnSurfaceVariant, + outline: _darkOutline, + outlineVariant: _darkSurfaceVariant, + ), + + // Typography - MUST match light theme for consistent layout + textTheme: const TextTheme( + headlineLarge: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + letterSpacing: -0.5, + height: 1.2, + ), + headlineMedium: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w600, + letterSpacing: 0, + height: 1.2, + ), + titleLarge: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + height: 1.3, + ), + titleMedium: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + letterSpacing: 0.15, + height: 1.4, + ), + bodyLarge: TextStyle( + fontSize: 16, + fontWeight: FontWeight.normal, + height: 1.5, + ), + bodyMedium: TextStyle( + fontSize: 14, + fontWeight: FontWeight.normal, + height: 1.5, + ), + labelLarge: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + ), + ), + + // Card theme - MUST match light theme structure for consistent layout cardTheme: CardThemeData( - color: const Color(0xFF1E1E1E), + elevation: 0, // Same as light theme + color: _darkSurface, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: const BorderSide(color: Color(0xFF444444), width: 1), + side: const BorderSide(color: _darkSurfaceVariant, width: 1), ), + margin: EdgeInsets.zero, // Same as light theme ), inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: const Color(0xFF2C2C2C), + fillColor: const Color(0xFF002A32), contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 16, @@ -192,30 +349,61 @@ class AppTheme { ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Color(0xFFB3C5FF), width: 2), + borderSide: const BorderSide(color: _darkPrimary, width: 2), ), ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFB3C5FF), - foregroundColor: const Color(0xFF002A78), + backgroundColor: _darkPrimary, + foregroundColor: _darkOnPrimary, elevation: 0, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), shape: const StadiumBorder(), + textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), + ), + ), + + // Outlined button theme - MUST match light theme for consistent layout + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: _darkPrimary, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + shape: const StadiumBorder(), + side: const BorderSide(color: _darkPrimary), + textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), ), ), + // AppBar theme - MUST match light theme for consistent layout + appBarTheme: const AppBarTheme( + backgroundColor: Colors.transparent, + elevation: 0, + centerTitle: false, + titleTextStyle: TextStyle( + color: _darkOnSurface, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + iconTheme: IconThemeData(color: _darkOnSurface), + ), + navigationBarTheme: NavigationBarThemeData( - backgroundColor: const Color(0xFF1E1E1E), - indicatorColor: const Color(0xFFB3C5FF).withValues(alpha: 0.2), + elevation: 0, // Same as light theme + backgroundColor: _darkSurface, + indicatorColor: _darkPrimary.withValues(alpha: 0.2), + labelBehavior: NavigationDestinationLabelBehavior + .alwaysShow, // Same as light theme iconTheme: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.selected)) { - return const IconThemeData(color: Color(0xFFB3C5FF)); + return const IconThemeData(color: _darkPrimary); } - return const IconThemeData(color: Color(0xFFC4C4C4)); + return const IconThemeData(color: _darkOnSurfaceVariant); }), ), + + // Theme Extensions + extensions: const >[TransitColors.dark], ); } } diff --git a/lib/src/core/theme/transit_colors.dart b/lib/src/core/theme/transit_colors.dart new file mode 100644 index 0000000..647271b --- /dev/null +++ b/lib/src/core/theme/transit_colors.dart @@ -0,0 +1,229 @@ +import 'package:flutter/material.dart'; + +/// Theme extension for transit line colors that adapt to light/dark mode. +/// These semantic colors ensure proper contrast and accessibility in both themes. +@immutable +class TransitColors extends ThemeExtension { + const TransitColors({ + required this.lrt1, + required this.lrt2, + required this.mrt3, + required this.mrt7, + required this.pnr, + required this.jeep, + required this.bus, + required this.discountStudent, + required this.discountSenior, + required this.discountPwd, + required this.discountBadge, + required this.discountBadgeText, + required this.successColor, + required this.onSuccessColor, + required this.successContainer, + required this.onSuccessContainer, + required this.originMarker, + required this.onOriginMarker, + required this.standardFare, + }); + + /// LRT-1 Line color (Green) + final Color lrt1; + + /// LRT-2 Line color (Purple) + final Color lrt2; + + /// MRT-3 Line color (Blue) + final Color mrt3; + + /// MRT-7 Line color (Orange) + final Color mrt7; + + /// PNR Line color (Brown) + final Color pnr; + + /// Jeepney route color (Teal) + final Color jeep; + + /// Bus route color (Red) + final Color bus; + + /// Student discount card color + final Color discountStudent; + + /// Senior citizen discount card color + final Color discountSenior; + + /// PWD discount card color + final Color discountPwd; + + /// Discount badge background color + final Color discountBadge; + + /// Discount badge text color + final Color discountBadgeText; + + /// Success/positive status color (e.g., downloaded, completed) + final Color successColor; + + /// Text/icon color on success color + final Color onSuccessColor; + + /// Success container/background color + final Color successContainer; + + /// Text/icon color on success container + final Color onSuccessContainer; + + /// Origin marker color on maps + final Color originMarker; + + /// Icon color on origin marker + final Color onOriginMarker; + + /// Standard fare indicator color + final Color standardFare; + + @override + TransitColors copyWith({ + Color? lrt1, + Color? lrt2, + Color? mrt3, + Color? mrt7, + Color? pnr, + Color? jeep, + Color? bus, + Color? discountStudent, + Color? discountSenior, + Color? discountPwd, + Color? discountBadge, + Color? discountBadgeText, + Color? successColor, + Color? onSuccessColor, + Color? successContainer, + Color? onSuccessContainer, + Color? originMarker, + Color? onOriginMarker, + Color? standardFare, + }) { + return TransitColors( + lrt1: lrt1 ?? this.lrt1, + lrt2: lrt2 ?? this.lrt2, + mrt3: mrt3 ?? this.mrt3, + mrt7: mrt7 ?? this.mrt7, + pnr: pnr ?? this.pnr, + jeep: jeep ?? this.jeep, + bus: bus ?? this.bus, + discountStudent: discountStudent ?? this.discountStudent, + discountSenior: discountSenior ?? this.discountSenior, + discountPwd: discountPwd ?? this.discountPwd, + discountBadge: discountBadge ?? this.discountBadge, + discountBadgeText: discountBadgeText ?? this.discountBadgeText, + successColor: successColor ?? this.successColor, + onSuccessColor: onSuccessColor ?? this.onSuccessColor, + successContainer: successContainer ?? this.successContainer, + onSuccessContainer: onSuccessContainer ?? this.onSuccessContainer, + originMarker: originMarker ?? this.originMarker, + onOriginMarker: onOriginMarker ?? this.onOriginMarker, + standardFare: standardFare ?? this.standardFare, + ); + } + + @override + TransitColors lerp(ThemeExtension? other, double t) { + if (other is! TransitColors) { + return this; + } + return TransitColors( + lrt1: Color.lerp(lrt1, other.lrt1, t)!, + lrt2: Color.lerp(lrt2, other.lrt2, t)!, + mrt3: Color.lerp(mrt3, other.mrt3, t)!, + mrt7: Color.lerp(mrt7, other.mrt7, t)!, + pnr: Color.lerp(pnr, other.pnr, t)!, + jeep: Color.lerp(jeep, other.jeep, t)!, + bus: Color.lerp(bus, other.bus, t)!, + discountStudent: Color.lerp(discountStudent, other.discountStudent, t)!, + discountSenior: Color.lerp(discountSenior, other.discountSenior, t)!, + discountPwd: Color.lerp(discountPwd, other.discountPwd, t)!, + discountBadge: Color.lerp(discountBadge, other.discountBadge, t)!, + discountBadgeText: Color.lerp( + discountBadgeText, + other.discountBadgeText, + t, + )!, + successColor: Color.lerp(successColor, other.successColor, t)!, + onSuccessColor: Color.lerp(onSuccessColor, other.onSuccessColor, t)!, + successContainer: Color.lerp( + successContainer, + other.successContainer, + t, + )!, + onSuccessContainer: Color.lerp( + onSuccessContainer, + other.onSuccessContainer, + t, + )!, + originMarker: Color.lerp(originMarker, other.originMarker, t)!, + onOriginMarker: Color.lerp(onOriginMarker, other.onOriginMarker, t)!, + standardFare: Color.lerp(standardFare, other.standardFare, t)!, + ); + } + + /// Light mode transit colors - saturated for visibility on light backgrounds + /// Colors adjusted to meet WCAG AA 3:1 minimum contrast ratio on white/light surfaces + static const light = TransitColors( + lrt1: Color( + 0xFF2E7D32, + ), // Darker Green - 4.52:1 on white (was #4CAF50 at 2.78:1) + lrt2: Color(0xFF7B1FA2), // Purple - passes + mrt3: Color( + 0xFF1565C0, + ), // Darker Blue - 4.62:1 on white (was #2196F3 at 2.72:1) + mrt7: Color( + 0xFFE65100, + ), // Darker Orange - 3.26:1 on white (was #FF9800 at 2.16:1) + pnr: Color(0xFF795548), // Brown - passes + jeep: Color(0xFF00695C), // Teal - passes + bus: Color(0xFFC62828), // Red - passes + discountStudent: Color(0xFF1976D2), // Blue - passes + discountSenior: Color(0xFF7B1FA2), // Purple - passes + discountPwd: Color(0xFF388E3C), // Green - passes + discountBadge: Color( + 0xFFA5D6A7, + ), // Lighter pastel green for better text contrast + discountBadgeText: Color(0xFF1B5E20), // Dark Green - 5.24:1 on #A5D6A7 + // Semantic status colors + successColor: Color(0xFF2E7D32), // Same as lrt1 green - passes WCAG + onSuccessColor: Color(0xFFFFFFFF), // White on green + successContainer: Color(0xFFC8E6C9), // Light green container + onSuccessContainer: Color(0xFF1B5E20), // Dark green text + originMarker: Color(0xFF2E7D32), // Green origin marker + onOriginMarker: Color(0xFFFFFFFF), // White icon on marker + standardFare: Color(0xFF2E7D32), // Green for standard fare indicator + ); + + /// Dark mode transit colors - highly desaturated/pastel for M3 dark mode + /// These colors are significantly muted to avoid eye strain on the + /// #141218 dark background and follow M3 tonal principles. + static const dark = TransitColors( + lrt1: Color(0xFFA8D5AA), // Desaturated pastel green + lrt2: Color(0xFFD4B8E0), // Desaturated pastel purple + mrt3: Color(0xFFABC8E8), // Desaturated pastel blue + mrt7: Color(0xFFE8CFA8), // Desaturated pastel orange + pnr: Color(0xFFC4B5AD), // Desaturated pastel brown + jeep: Color(0xFF9DCDC6), // Desaturated pastel teal + bus: Color(0xFFE8AEAB), // Desaturated pastel red + discountStudent: Color(0xFFABC8E8), // Desaturated pastel blue + discountSenior: Color(0xFFD4B8E0), // Desaturated pastel purple + discountPwd: Color(0xFFA8D5AA), // Desaturated pastel green + discountBadge: Color(0xFFA8D5AA), // Desaturated pastel green + discountBadgeText: Color(0xFF1B3D1D), // Dark green for contrast on pastel + // Semantic status colors for dark mode + successColor: Color(0xFFA8D5AA), // Pastel green for dark mode + onSuccessColor: Color(0xFF1B3D1D), // Dark green on pastel + successContainer: Color(0xFF1B3D1D), // Dark green container + onSuccessContainer: Color(0xFFA8D5AA), // Pastel green text + originMarker: Color(0xFFA8D5AA), // Pastel green origin marker + onOriginMarker: Color(0xFF1B3D1D), // Dark icon on marker + standardFare: Color(0xFFA8D5AA), // Pastel green for standard fare + ); +} diff --git a/lib/src/l10n/app_en.arb b/lib/src/l10n/app_en.arb index 9c7bb36..4adb7b5 100644 --- a/lib/src/l10n/app_en.arb +++ b/lib/src/l10n/app_en.arb @@ -12,8 +12,11 @@ "settingsTitle": "Settings", "provincialModeTitle": "Provincial Mode", "provincialModeSubtitle": "Enable provincial fare rates", - "highContrastModeTitle": "High Contrast Mode", - "highContrastModeSubtitle": "Increase contrast for better visibility", + "themeModeTitle": "Theme", + "themeModeSubtitle": "Choose your preferred appearance", + "themeModeSystem": "System", + "themeModeLight": "Light", + "themeModeDark": "Dark", "trafficFactorTitle": "Traffic Factor", "trafficFactorSubtitle": "Adjusts the fare calculation based on expected traffic conditions.", "trafficLow": "Low", @@ -28,5 +31,7 @@ "onboardingKnowFareDescription": "Calculate fares for jeepneys, buses, trains, and ferries across the Philippines.", "onboardingWorkOfflineTitle": "Work Offline", "onboardingWorkOfflineDescription": "Access fare data anytime, even without an internet connection.", - "onboardingLanguageTitle": "Choose Your Language" + "onboardingLanguageTitle": "Choose Your Language", + "sourceCodeTitle": "Source Code", + "sourceCodeSubtitle": "View on GitHub" } \ No newline at end of file diff --git a/lib/src/l10n/app_localizations.dart b/lib/src/l10n/app_localizations.dart index d0fe3ee..c864274 100644 --- a/lib/src/l10n/app_localizations.dart +++ b/lib/src/l10n/app_localizations.dart @@ -176,17 +176,35 @@ abstract class AppLocalizations { /// **'Enable provincial fare rates'** String get provincialModeSubtitle; - /// No description provided for @highContrastModeTitle. + /// No description provided for @themeModeTitle. /// /// In en, this message translates to: - /// **'High Contrast Mode'** - String get highContrastModeTitle; + /// **'Theme'** + String get themeModeTitle; - /// No description provided for @highContrastModeSubtitle. + /// No description provided for @themeModeSubtitle. /// /// In en, this message translates to: - /// **'Increase contrast for better visibility'** - String get highContrastModeSubtitle; + /// **'Choose your preferred appearance'** + String get themeModeSubtitle; + + /// No description provided for @themeModeSystem. + /// + /// In en, this message translates to: + /// **'System'** + String get themeModeSystem; + + /// No description provided for @themeModeLight. + /// + /// In en, this message translates to: + /// **'Light'** + String get themeModeLight; + + /// No description provided for @themeModeDark. + /// + /// In en, this message translates to: + /// **'Dark'** + String get themeModeDark; /// No description provided for @trafficFactorTitle. /// @@ -277,6 +295,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Choose Your Language'** String get onboardingLanguageTitle; + + /// No description provided for @sourceCodeTitle. + /// + /// In en, this message translates to: + /// **'Source Code'** + String get sourceCodeTitle; + + /// No description provided for @sourceCodeSubtitle. + /// + /// In en, this message translates to: + /// **'View on GitHub'** + String get sourceCodeSubtitle; } class _AppLocalizationsDelegate diff --git a/lib/src/l10n/app_localizations_en.dart b/lib/src/l10n/app_localizations_en.dart index b0c2d1b..11a778c 100644 --- a/lib/src/l10n/app_localizations_en.dart +++ b/lib/src/l10n/app_localizations_en.dart @@ -49,11 +49,19 @@ class AppLocalizationsEn extends AppLocalizations { String get provincialModeSubtitle => 'Enable provincial fare rates'; @override - String get highContrastModeTitle => 'High Contrast Mode'; + String get themeModeTitle => 'Theme'; @override - String get highContrastModeSubtitle => - 'Increase contrast for better visibility'; + String get themeModeSubtitle => 'Choose your preferred appearance'; + + @override + String get themeModeSystem => 'System'; + + @override + String get themeModeLight => 'Light'; + + @override + String get themeModeDark => 'Dark'; @override String get trafficFactorTitle => 'Traffic Factor'; @@ -102,4 +110,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get onboardingLanguageTitle => 'Choose Your Language'; + + @override + String get sourceCodeTitle => 'Source Code'; + + @override + String get sourceCodeSubtitle => 'View on GitHub'; } diff --git a/lib/src/l10n/app_localizations_tl.dart b/lib/src/l10n/app_localizations_tl.dart index 476c470..9bdb9ea 100644 --- a/lib/src/l10n/app_localizations_tl.dart +++ b/lib/src/l10n/app_localizations_tl.dart @@ -50,11 +50,19 @@ class AppLocalizationsTl extends AppLocalizations { 'Paganahin ang mga presyo ng pamasahe sa probinsya'; @override - String get highContrastModeTitle => 'High Contrast Mode'; + String get themeModeTitle => 'Tema'; @override - String get highContrastModeSubtitle => - 'Taasan ang contrast para sa mas malinaw na pagtingin'; + String get themeModeSubtitle => 'Piliin ang iyong gustong hitsura'; + + @override + String get themeModeSystem => 'System'; + + @override + String get themeModeLight => 'Maliwanag'; + + @override + String get themeModeDark => 'Madilim'; @override String get trafficFactorTitle => 'Antas ng Trapiko'; @@ -103,4 +111,10 @@ class AppLocalizationsTl extends AppLocalizations { @override String get onboardingLanguageTitle => 'Pumili ng Wika'; + + @override + String get sourceCodeTitle => 'Source Code'; + + @override + String get sourceCodeSubtitle => 'Tingnan sa GitHub'; } diff --git a/lib/src/l10n/app_tl.arb b/lib/src/l10n/app_tl.arb index 04c1e85..a2cd497 100644 --- a/lib/src/l10n/app_tl.arb +++ b/lib/src/l10n/app_tl.arb @@ -12,8 +12,11 @@ "settingsTitle": "Mga Setting", "provincialModeTitle": "Provincial Mode", "provincialModeSubtitle": "Paganahin ang mga presyo ng pamasahe sa probinsya", - "highContrastModeTitle": "High Contrast Mode", - "highContrastModeSubtitle": "Taasan ang contrast para sa mas malinaw na pagtingin", + "themeModeTitle": "Tema", + "themeModeSubtitle": "Piliin ang iyong gustong hitsura", + "themeModeSystem": "System", + "themeModeLight": "Maliwanag", + "themeModeDark": "Madilim", "trafficFactorTitle": "Antas ng Trapiko", "trafficFactorSubtitle": "Isinasaayos ang kalkulasyon ng pamasahe batay sa inaasahang kondisyon ng trapiko.", "trafficLow": "Mababa", @@ -28,5 +31,7 @@ "onboardingKnowFareDescription": "Kalkulahin ang pamasahe para sa jeepney, bus, tren, at ferry sa buong Pilipinas.", "onboardingWorkOfflineTitle": "Magtrabaho Offline", "onboardingWorkOfflineDescription": "Ma-access ang datos ng pamasahe kahit walang internet connection.", - "onboardingLanguageTitle": "Pumili ng Wika" + "onboardingLanguageTitle": "Pumili ng Wika", + "sourceCodeTitle": "Source Code", + "sourceCodeSubtitle": "Tingnan sa GitHub" } \ No newline at end of file diff --git a/lib/src/presentation/screens/main_screen.dart b/lib/src/presentation/screens/main_screen.dart index 74cbd37..d5bd5b3 100644 --- a/lib/src/presentation/screens/main_screen.dart +++ b/lib/src/presentation/screens/main_screen.dart @@ -154,10 +154,16 @@ class _MainScreenState extends State { onSortChanged: _controller.setSortCriteria, ), const SizedBox(height: 16), - MapPreview( - origin: _controller.originLatLng, - destination: _controller.destinationLatLng, - routePoints: _controller.routePoints, + // Map height: 280 when no fare results (40% larger), 200 when showing results + AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + child: MapPreview( + origin: _controller.originLatLng, + destination: _controller.destinationLatLng, + routePoints: _controller.routePoints, + height: _controller.fareResults.isEmpty ? 280 : 200, + ), ), const SizedBox(height: 24), CalculateFareButton( diff --git a/lib/src/presentation/screens/map_picker_screen.dart b/lib/src/presentation/screens/map_picker_screen.dart index 39ab98a..0964965 100644 --- a/lib/src/presentation/screens/map_picker_screen.dart +++ b/lib/src/presentation/screens/map_picker_screen.dart @@ -249,17 +249,29 @@ class _MapPickerScreenState extends State } /// Builds the tile layer, using cached tiles when available. + /// The tile style automatically adjusts based on the current theme (light/dark). + /// For dark mode, CartoDB Voyager tiles are inverted using ColorFiltered. Widget _buildTileLayer() { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + Widget tileLayer; + try { final offlineMapService = getIt(); - return offlineMapService.getCachedTileLayer(); + tileLayer = offlineMapService.getThemedCachedTileLayer( + isDarkMode: isDarkMode, + ); } catch (_) { // Fall back to network tiles if service not initialized - return TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'com.ph_fare_calculator', - ); + tileLayer = OfflineMapService.getNetworkTileLayer(isDarkMode: isDarkMode); } + + // Apply color inversion for dark mode to create dark appearance from Voyager tiles + if (isDarkMode) { + return OfflineMapService.wrapWithDarkModeFilter(tileLayer); + } + + return tileLayer; } Widget _buildAnimatedCenterPin(ColorScheme colorScheme) { @@ -320,7 +332,7 @@ class _MapPickerScreenState extends State decoration: BoxDecoration( color: colorScheme.primary, shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 3), + border: Border.all(color: colorScheme.surface, width: 3), ), child: Icon(Icons.place, color: colorScheme.onPrimary, size: 28), ), diff --git a/lib/src/presentation/screens/offline_menu_screen.dart b/lib/src/presentation/screens/offline_menu_screen.dart index a3d777c..8d90035 100644 --- a/lib/src/presentation/screens/offline_menu_screen.dart +++ b/lib/src/presentation/screens/offline_menu_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../core/di/injection.dart'; +import '../../core/theme/transit_colors.dart'; import '../../models/map_region.dart'; import '../../services/offline/offline_map_service.dart'; import 'reference_screen.dart'; @@ -87,9 +88,13 @@ class _OfflineMenuScreenState extends State description: 'Learn about available discounts for students, seniors, and PWD.', icon: Icons.percent_rounded, - iconBackgroundColor: Colors.green.withValues(alpha: 0.12), - iconColor: Colors.green, - destination: const ReferenceScreen(), + iconBackgroundColor: + Theme.of(context).extension()?.successContainer ?? + colorScheme.primaryContainer, + iconColor: + Theme.of(context).extension()?.successColor ?? + colorScheme.primary, + destination: const ReferenceScreen(initialTabIndex: 3), ), ]; } diff --git a/lib/src/presentation/screens/onboarding_screen.dart b/lib/src/presentation/screens/onboarding_screen.dart index 6f8bd14..72354b1 100644 --- a/lib/src/presentation/screens/onboarding_screen.dart +++ b/lib/src/presentation/screens/onboarding_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; + import '../../l10n/app_localizations.dart'; import 'package:ph_fare_calculator/src/core/di/injection.dart'; +import 'package:ph_fare_calculator/src/presentation/widgets/app_logo_widget.dart'; import 'package:ph_fare_calculator/src/services/settings_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:ph_fare_calculator/src/presentation/screens/main_screen.dart'; @@ -31,11 +33,6 @@ class _OnboardingScreenState extends State late final Animation _fadeAnimation; late final Animation _slideAnimation; - // Brand colors from AppTheme - static const Color _primaryBlue = Color(0xFF0038A8); - static const Color _secondaryYellow = Color(0xFFFCD116); - static const Color _tertiaryRed = Color(0xFFCE1126); - // Total pages count static const int _totalPages = 3; @@ -135,7 +132,7 @@ class _OnboardingScreenState extends State end: Alignment.bottomCenter, colors: [ colorScheme.primary.withValues(alpha: 0.05), - Colors.white, + colorScheme.surface, ], ), ), @@ -172,6 +169,7 @@ class _OnboardingScreenState extends State } Widget _buildSkipButton(AppLocalizations l10n) { + final colorScheme = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: Row( @@ -183,7 +181,7 @@ class _OnboardingScreenState extends State child: TextButton( onPressed: _skipOnboarding, style: TextButton.styleFrom( - foregroundColor: Colors.grey.shade600, + foregroundColor: colorScheme.onSurfaceVariant, padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, @@ -204,22 +202,54 @@ class _OnboardingScreenState extends State } Widget _buildKnowYourFarePage(AppLocalizations l10n, ThemeData theme) { - return _buildOnboardingPage( - icon: Icons.directions_bus_rounded, - iconColor: _primaryBlue, - iconBackgroundColor: _primaryBlue.withValues(alpha: 0.1), - title: l10n.welcomeTitle, - description: l10n.onboardingKnowFareDescription, - theme: theme, - semanticLabel: 'Know Your Fare - Calculate fares for different transport', + final colorScheme = theme.colorScheme; + return Semantics( + label: 'Know Your Fare - Calculate fares for different transport', + child: SlideTransition( + position: _slideAnimation, + child: FadeTransition( + opacity: _fadeAnimation, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Use AppLogoWidget for visual consistency with splash screen + const AppLogoWidget(size: AppLogoSize.large, showShadow: true), + const SizedBox(height: 48), + // Title + Text( + l10n.welcomeTitle, + style: theme.textTheme.headlineLarge?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + // Description + Text( + l10n.onboardingKnowFareDescription, + style: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.5, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), ); } Widget _buildWorkOfflinePage(AppLocalizations l10n, ThemeData theme) { + final colorScheme = theme.colorScheme; return _buildOnboardingPage( icon: Icons.cloud_download_rounded, - iconColor: _secondaryYellow.withValues(alpha: 0.9), - iconBackgroundColor: _secondaryYellow.withValues(alpha: 0.15), + iconColor: colorScheme.secondary, + iconBackgroundColor: colorScheme.secondaryContainer, title: l10n.onboardingWorkOfflineTitle, description: l10n.onboardingWorkOfflineDescription, theme: theme, @@ -228,6 +258,7 @@ class _OnboardingScreenState extends State } Widget _buildLanguageSelectionPage(AppLocalizations l10n, ThemeData theme) { + final colorScheme = theme.colorScheme; return SlideTransition( position: _slideAnimation, child: FadeTransition( @@ -245,13 +276,13 @@ class _OnboardingScreenState extends State width: 100, height: 100, decoration: BoxDecoration( - color: _tertiaryRed.withValues(alpha: 0.1), + color: colorScheme.tertiaryContainer, shape: BoxShape.circle, ), child: Icon( Icons.translate_rounded, size: 48, - color: _tertiaryRed.withValues(alpha: 0.9), + color: colorScheme.tertiary, ), ), ), @@ -261,7 +292,7 @@ class _OnboardingScreenState extends State l10n.onboardingLanguageTitle, style: theme.textTheme.headlineLarge?.copyWith( fontWeight: FontWeight.bold, - color: const Color(0xFF1A1C1E), + color: colorScheme.onSurface, ), textAlign: TextAlign.center, ), @@ -270,7 +301,7 @@ class _OnboardingScreenState extends State Text( l10n.selectLanguage, style: theme.textTheme.bodyLarge?.copyWith( - color: Colors.grey.shade600, + color: colorScheme.onSurfaceVariant, height: 1.5, ), textAlign: TextAlign.center, @@ -326,6 +357,7 @@ class _OnboardingScreenState extends State required ThemeData theme, required VoidCallback onTap, }) { + final colorScheme = theme.colorScheme; return Semantics( label: 'Select $language language${isSelected ? ", currently selected" : ""}', @@ -339,17 +371,17 @@ class _OnboardingScreenState extends State padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), decoration: BoxDecoration( color: isSelected - ? _primaryBlue.withValues(alpha: 0.1) - : Colors.white, + ? colorScheme.primaryContainer + : colorScheme.surface, borderRadius: BorderRadius.circular(16), border: Border.all( - color: isSelected ? _primaryBlue : Colors.grey.shade300, + color: isSelected ? colorScheme.primary : colorScheme.outline, width: isSelected ? 2 : 1, ), boxShadow: isSelected ? [ BoxShadow( - color: _primaryBlue.withValues(alpha: 0.15), + color: colorScheme.primary.withValues(alpha: 0.15), blurRadius: 12, offset: const Offset(0, 4), ), @@ -364,8 +396,8 @@ class _OnboardingScreenState extends State height: 48, decoration: BoxDecoration( color: isSelected - ? _primaryBlue.withValues(alpha: 0.15) - : Colors.grey.shade100, + ? colorScheme.primary.withValues(alpha: 0.15) + : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), ), child: Center( @@ -382,7 +414,9 @@ class _OnboardingScreenState extends State language, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, - color: isSelected ? _primaryBlue : const Color(0xFF1A1C1E), + color: isSelected + ? colorScheme.primary + : colorScheme.onSurface, ), ), ), @@ -394,12 +428,12 @@ class _OnboardingScreenState extends State width: 28, height: 28, decoration: BoxDecoration( - color: _primaryBlue, + color: colorScheme.primary, shape: BoxShape.circle, ), - child: const Icon( + child: Icon( Icons.check_rounded, - color: Colors.white, + color: colorScheme.onPrimary, size: 18, ), ), @@ -420,6 +454,7 @@ class _OnboardingScreenState extends State required ThemeData theme, required String semanticLabel, }) { + final colorScheme = theme.colorScheme; return Semantics( label: semanticLabel, child: SlideTransition( @@ -447,7 +482,7 @@ class _OnboardingScreenState extends State title, style: theme.textTheme.headlineLarge?.copyWith( fontWeight: FontWeight.bold, - color: const Color(0xFF1A1C1E), + color: colorScheme.onSurface, ), textAlign: TextAlign.center, ), @@ -456,7 +491,7 @@ class _OnboardingScreenState extends State Text( description, style: theme.textTheme.bodyLarge?.copyWith( - color: Colors.grey.shade600, + color: colorScheme.onSurfaceVariant, height: 1.5, ), textAlign: TextAlign.center, @@ -480,6 +515,7 @@ class _OnboardingScreenState extends State } Widget _buildDot(int index) { + final colorScheme = Theme.of(context).colorScheme; final isActive = index == _currentPage; return AnimatedContainer( duration: const Duration(milliseconds: 300), @@ -488,13 +524,16 @@ class _OnboardingScreenState extends State width: isActive ? 32 : 10, height: 10, decoration: BoxDecoration( - color: isActive ? _primaryBlue : _primaryBlue.withValues(alpha: 0.25), + color: isActive + ? colorScheme.primary + : colorScheme.primary.withValues(alpha: 0.25), borderRadius: BorderRadius.circular(5), ), ); } Widget _buildBottomButtons(AppLocalizations l10n, ThemeData theme) { + final colorScheme = theme.colorScheme; final isLastPage = _currentPage == _totalPages - 1; return Padding( @@ -512,8 +551,8 @@ class _OnboardingScreenState extends State child: ElevatedButton( onPressed: _goToNextPage, style: ElevatedButton.styleFrom( - backgroundColor: _primaryBlue, - foregroundColor: Colors.white, + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, padding: const EdgeInsets.symmetric(vertical: 16), shape: const StadiumBorder(), elevation: 0, @@ -536,7 +575,7 @@ class _OnboardingScreenState extends State child: Text( l10n.disclaimer, style: TextStyle( - color: Colors.grey.shade500, + color: colorScheme.outline, fontSize: 12, height: 1.4, ), diff --git a/lib/src/presentation/screens/reference_screen.dart b/lib/src/presentation/screens/reference_screen.dart index 945cf82..25fc62e 100644 --- a/lib/src/presentation/screens/reference_screen.dart +++ b/lib/src/presentation/screens/reference_screen.dart @@ -3,11 +3,19 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../../src/models/fare_formula.dart'; import '../../../src/models/static_fare.dart'; +import '../../core/theme/transit_colors.dart'; /// A modern reference screen with tab-based navigation for fare information. /// Displays Road, Train, Ferry fares and Discount information with search functionality. class ReferenceScreen extends StatefulWidget { - const ReferenceScreen({super.key}); + /// Creates a ReferenceScreen. + /// + /// [initialTabIndex] - The tab index to show initially (0=Road, 1=Train, 2=Ferry, 3=Discount Guide). + const ReferenceScreen({super.key, this.initialTabIndex = 0}); + + /// The initial tab index to display when the screen opens. + /// Defaults to 0 (Road Transport tab). + final int initialTabIndex; @override State createState() => _ReferenceScreenState(); @@ -28,7 +36,13 @@ class _ReferenceScreenState extends State @override void initState() { super.initState(); - _tabController = TabController(length: 4, vsync: this); + // Clamp initialTabIndex to valid range [0, 3] + final clampedIndex = widget.initialTabIndex.clamp(0, 3); + _tabController = TabController( + length: 4, + vsync: this, + initialIndex: clampedIndex, + ); _tabController.addListener(_onTabChanged); _loadReferenceData(); } @@ -100,25 +114,19 @@ class _ReferenceScreenState extends State final colorScheme = theme.colorScheme; return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + tooltip: 'Back', + ), + title: const Text('Fare Reference Guide'), + centerTitle: false, + ), body: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Custom App Bar with title - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Semantics( - header: true, - child: Text( - 'Fare Reference Guide', - style: theme.textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - ), - ), - // Search Bar Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -167,6 +175,8 @@ class _ReferenceScreenState extends State ), child: TabBar( controller: _tabController, + isScrollable: true, + tabAlignment: TabAlignment.start, labelColor: colorScheme.primary, unselectedLabelColor: colorScheme.onSurfaceVariant, indicatorSize: TabBarIndicatorSize.tab, @@ -184,7 +194,7 @@ class _ReferenceScreenState extends State _buildTab(Icons.directions_bus_rounded, 'Road'), _buildTab(Icons.train_rounded, 'Train'), _buildTab(Icons.directions_boat_rounded, 'Ferry'), - _buildTab(Icons.info_outline_rounded, 'Discounts'), + _buildTab(Icons.info_outline_rounded, 'Discount Guide'), ], ), ), @@ -648,26 +658,57 @@ class _TrainLineCard extends StatelessWidget { required this.index, }); - Color _getLineColor(String name) { + Color _getLineColor(BuildContext context, String name) { + final transitColors = Theme.of(context).extension(); + if (transitColors == null) { + // Fallback to default colors if extension not found + if (name.contains('LRT-1') || name.contains('LRT1')) { + return const Color(0xFF4CAF50); + } else if (name.contains('LRT-2') || name.contains('LRT2')) { + return const Color(0xFF7B1FA2); + } else if (name.contains('MRT-3') || name.contains('MRT3')) { + return const Color(0xFF2196F3); + } else if (name.contains('MRT-7') || name.contains('MRT7')) { + return const Color(0xFFFF9800); + } else if (name.contains('PNR')) { + return const Color(0xFF795548); + } + return const Color(0xFF607D8B); + } + if (name.contains('LRT-1') || name.contains('LRT1')) { - return const Color(0xFF4CAF50); // Green + return transitColors.lrt1; } else if (name.contains('LRT-2') || name.contains('LRT2')) { - return const Color(0xFF7B1FA2); // Purple + return transitColors.lrt2; } else if (name.contains('MRT-3') || name.contains('MRT3')) { - return const Color(0xFF2196F3); // Blue + return transitColors.mrt3; } else if (name.contains('MRT-7') || name.contains('MRT7')) { - return const Color(0xFFFF9800); // Orange + return transitColors.mrt7; } else if (name.contains('PNR')) { - return const Color(0xFF795548); // Brown + return transitColors.pnr; } - return const Color(0xFF607D8B); // Default grey-blue + return Theme.of(context).colorScheme.outline; + } + + /// Gets the appropriate text color for the train line header based on the line color. + /// Uses dark text on light/pastel backgrounds for better contrast in dark mode. + Color _getHeaderTextColor(BuildContext context, Color lineColor) { + // Calculate relative luminance to determine if the background is light or dark + final luminance = lineColor.computeLuminance(); + // For light/pastel colors (luminance > 0.5), use dark text + // For dark colors, use white text + if (luminance > 0.5) { + return const Color(0xFF1B1B1F); // Dark text for light backgrounds + } + return Colors.white; } @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final lineColor = _getLineColor(lineName); + final lineColor = _getLineColor(context, lineName); + final headerTextColor = _getHeaderTextColor(context, lineColor); // Calculate stats final maxFare = routes.map((r) => r.price).reduce((a, b) => a > b ? a : b); @@ -712,12 +753,12 @@ class _TrainLineCard extends StatelessWidget { width: 44, height: 44, decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), + color: headerTextColor.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(12), ), - child: const Icon( + child: Icon( Icons.train_rounded, - color: Colors.white, + color: headerTextColor, size: 24, ), ), @@ -730,13 +771,13 @@ class _TrainLineCard extends StatelessWidget { lineName, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, - color: Colors.white, + color: headerTextColor, ), ), Text( '${uniqueStations.length} stations • ${routes.length} routes', style: theme.textTheme.bodySmall?.copyWith( - color: Colors.white.withValues(alpha: 0.8), + color: headerTextColor.withValues(alpha: 0.8), ), ), ], @@ -748,13 +789,13 @@ class _TrainLineCard extends StatelessWidget { vertical: 6, ), decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), + color: headerTextColor.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(20), ), child: Text( '₱${minFare.toStringAsFixed(0)} - ₱${maxFare.toStringAsFixed(0)}', style: theme.textTheme.labelMedium?.copyWith( - color: Colors.white, + color: headerTextColor, fontWeight: FontWeight.bold, ), ), @@ -1109,6 +1150,7 @@ class _DiscountInfoTab extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; + final transitColors = theme.extension(); final discounts = [ _DiscountData( @@ -1117,7 +1159,7 @@ class _DiscountInfoTab extends StatelessWidget { discount: '20% off', description: 'Valid student ID from accredited institution required. Must be enrolled in current academic year.', - color: const Color(0xFF2196F3), + color: transitColors?.discountStudent ?? const Color(0xFF2196F3), ), _DiscountData( icon: Icons.elderly_rounded, @@ -1125,7 +1167,7 @@ class _DiscountInfoTab extends StatelessWidget { discount: '20% off', description: 'Senior Citizen ID or valid government ID showing age 60 and above required.', - color: const Color(0xFF9C27B0), + color: transitColors?.discountSenior ?? const Color(0xFF9C27B0), ), _DiscountData( icon: Icons.accessible_rounded, @@ -1133,7 +1175,7 @@ class _DiscountInfoTab extends StatelessWidget { discount: '20% off', description: 'PWD ID issued by the local government required. Companion may also be entitled to discount.', - color: const Color(0xFF4CAF50), + color: transitColors?.discountPwd ?? const Color(0xFF4CAF50), ), ]; @@ -1334,6 +1376,14 @@ class _DiscountCard extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + final transitColors = theme.extension(); + final badgeColor = transitColors?.discountBadge ?? const Color(0xFF4CAF50); + // In dark mode, use the badge color itself (light pastel green) for text visibility + // In light mode, use the dark green text color for contrast on light background + final badgeTextColor = isDark + ? badgeColor + : (transitColors?.discountBadgeText ?? const Color(0xFF2E7D32)); return TweenAnimationBuilder( duration: Duration(milliseconds: 400 + (index * 100)), @@ -1387,21 +1437,21 @@ class _DiscountCard extends StatelessWidget { vertical: 4, ), decoration: BoxDecoration( - color: const Color( - 0xFF4CAF50, - ).withValues(alpha: 0.1), + color: badgeColor.withValues( + alpha: isDark ? 0.2 : 0.1, + ), borderRadius: BorderRadius.circular(20), border: Border.all( - color: const Color( - 0xFF4CAF50, - ).withValues(alpha: 0.3), + color: badgeColor.withValues( + alpha: isDark ? 0.5 : 0.3, + ), ), ), child: Text( data.discount, style: theme.textTheme.labelMedium?.copyWith( fontWeight: FontWeight.bold, - color: const Color(0xFF2E7D32), + color: badgeTextColor, ), ), ), diff --git a/lib/src/presentation/screens/region_download_screen.dart b/lib/src/presentation/screens/region_download_screen.dart index 489b040..7b42fd3 100644 --- a/lib/src/presentation/screens/region_download_screen.dart +++ b/lib/src/presentation/screens/region_download_screen.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../../core/di/injection.dart'; +import '../../core/theme/transit_colors.dart'; import '../../models/map_region.dart'; import '../../services/offline/offline_map_service.dart'; @@ -88,10 +89,13 @@ class _RegionDownloadScreenState extends State { if (progress.isComplete) { _loadStorageInfo(); if (mounted) { + final transitColors = Theme.of(context).extension(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('${progress.region.name} downloaded successfully!'), - backgroundColor: Colors.green, + backgroundColor: + transitColors?.successColor ?? + Theme.of(context).colorScheme.primary, ), ); } @@ -250,10 +254,13 @@ class _RegionDownloadScreenState extends State { await _loadStorageInfo(); setState(() {}); if (mounted) { + final transitColors = Theme.of(context).extension(); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('All offline map data cleared'), - backgroundColor: Colors.green, + SnackBar( + content: const Text('All offline map data cleared'), + backgroundColor: + transitColors?.successColor ?? + Theme.of(context).colorScheme.primary, ), ); } @@ -452,9 +459,11 @@ class _RegionDownloadScreenState extends State { ); final partiallyDownloaded = downloadedCount > 0 && !allDownloaded; + final transitColors = Theme.of(context).extension(); Color? cardBackground; if (allDownloaded) { - cardBackground = Colors.green.withValues(alpha: 0.1); + cardBackground = + transitColors?.successContainer ?? colorScheme.primaryContainer; } else if (partiallyDownloaded) { cardBackground = colorScheme.primaryContainer.withValues(alpha: 0.3); } @@ -588,8 +597,12 @@ class _RegionDownloadScreenState extends State { } if (allDownloaded) { + final transitColors = Theme.of(context).extension(); return PopupMenuButton( - icon: const Icon(Icons.check_circle, color: Colors.green), + icon: Icon( + Icons.check_circle, + color: transitColors?.successColor ?? colorScheme.primary, + ), tooltip: 'Options', onSelected: (value) { if (value == 'delete') { @@ -601,7 +614,7 @@ class _RegionDownloadScreenState extends State { value: 'delete', child: Row( children: [ - Icon(Icons.delete, color: Colors.red), + Icon(Icons.delete, color: colorScheme.error), const SizedBox(width: 8), const Text('Delete All'), ], @@ -627,11 +640,13 @@ class _RegionDownloadScreenState extends State { ColorScheme colorScheme, MapRegion island, ) { + final transitColors = Theme.of(context).extension(); return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: island.status.isAvailableOffline - ? Colors.green.withValues(alpha: 0.05) + ? (transitColors?.successContainer ?? colorScheme.primaryContainer) + .withValues(alpha: 0.3) : null, borderRadius: BorderRadius.circular(8), ), @@ -697,10 +712,15 @@ class _RegionDownloadScreenState extends State { } Widget _buildIslandStatusIcon(ColorScheme colorScheme, MapRegion island) { + final transitColors = Theme.of(context).extension(); switch (island.status) { case DownloadStatus.downloaded: case DownloadStatus.updateAvailable: - return Icon(Icons.check_circle, color: Colors.green, size: 20); + return Icon( + Icons.check_circle, + color: transitColors?.successColor ?? colorScheme.primary, + size: 20, + ); case DownloadStatus.downloading: return SizedBox( width: 20, diff --git a/lib/src/presentation/screens/settings_screen.dart b/lib/src/presentation/screens/settings_screen.dart index ffd9134..f7d1ac9 100644 --- a/lib/src/presentation/screens/settings_screen.dart +++ b/lib/src/presentation/screens/settings_screen.dart @@ -1,5 +1,10 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../core/constants/app_constants.dart'; import '../../core/di/injection.dart'; import '../../l10n/app_localizations.dart'; import '../../models/discount_type.dart'; @@ -7,6 +12,7 @@ import '../../models/fare_formula.dart'; import '../../models/transport_mode.dart'; import '../../repositories/fare_repository.dart'; import '../../services/settings_service.dart'; +import '../widgets/app_logo_widget.dart'; /// Modern settings screen with grouped sections and Material 3 styling. /// Follows 8dp grid system and uses theme colors from AppTheme. @@ -26,7 +32,7 @@ class _SettingsScreenState extends State late final AnimationController _animationController; bool _isProvincialModeEnabled = false; - bool _isHighContrastEnabled = false; + String _themeMode = 'system'; TrafficFactor _trafficFactor = TrafficFactor.medium; DiscountType _discountType = DiscountType.standard; Locale _currentLocale = const Locale('en'); @@ -35,9 +41,9 @@ class _SettingsScreenState extends State Set _hiddenTransportModes = {}; Map> _groupedFormulas = {}; - // App version info - static const String _appVersion = '1.0.0'; - static const String _buildNumber = '1'; + // App version info (loaded dynamically from pubspec.yaml) + String _appVersion = ''; + String _buildNumber = ''; @override void initState() { @@ -60,12 +66,26 @@ class _SettingsScreenState extends State Future _loadSettings() async { final provincialMode = await _settingsService.getProvincialMode(); final trafficFactor = await _settingsService.getTrafficFactor(); - final highContrast = await _settingsService.getHighContrastEnabled(); + final themeMode = await _settingsService.getThemeMode(); final discountType = await _settingsService.getUserDiscountType(); final hiddenModes = await _settingsService.getHiddenTransportModes(); final locale = await _settingsService.getLocale(); final formulas = await _fareRepository.getAllFormulas(); + // Load package info with fallback for test environment + String version = '2.0.0'; + String buildNumber = '2'; + try { + final packageInfo = await PackageInfo.fromPlatform().timeout( + const Duration(seconds: 1), + onTimeout: () => throw TimeoutException('Platform info timeout'), + ); + version = packageInfo.version; + buildNumber = packageInfo.buildNumber; + } catch (_) { + // Use fallback values if platform info is unavailable (e.g., in tests) + } + // Group formulas by mode final grouped = >{}; for (final formula in formulas) { @@ -78,12 +98,14 @@ class _SettingsScreenState extends State if (mounted) { setState(() { _isProvincialModeEnabled = provincialMode; - _isHighContrastEnabled = highContrast; + _themeMode = themeMode; _trafficFactor = trafficFactor; _discountType = discountType; _currentLocale = locale; _hiddenTransportModes = hiddenModes; _groupedFormulas = grouped; + _appVersion = version; + _buildNumber = buildNumber; _isLoading = false; }); _animationController.forward(); @@ -159,21 +181,7 @@ class _SettingsScreenState extends State _buildSettingsCard( context, children: [ - _buildSwitchTile( - context, - title: AppLocalizations.of( - context, - )!.highContrastModeTitle, - subtitle: AppLocalizations.of( - context, - )!.highContrastModeSubtitle, - value: _isHighContrastEnabled, - icon: Icons.contrast_rounded, - onChanged: (value) async { - setState(() => _isHighContrastEnabled = value); - await _settingsService.setHighContrastEnabled(value); - }, - ), + _buildThemeModeTile(context), const Divider(height: 1, indent: 56), _buildLanguageTile(context), ], @@ -207,7 +215,10 @@ class _SettingsScreenState extends State )!.trafficLowSubtitle, value: TrafficFactor.low, icon: Icons.speed_rounded, - iconColor: Colors.green, + iconColor: _getTrafficIconColor( + context, + TrafficFactor.low, + ), ), _buildTrafficFactorTile( context, @@ -217,7 +228,10 @@ class _SettingsScreenState extends State )!.trafficMediumSubtitle, value: TrafficFactor.medium, icon: Icons.speed_rounded, - iconColor: Colors.orange, + iconColor: _getTrafficIconColor( + context, + TrafficFactor.medium, + ), ), _buildTrafficFactorTile( context, @@ -227,7 +241,10 @@ class _SettingsScreenState extends State )!.trafficHighSubtitle, value: TrafficFactor.high, icon: Icons.speed_rounded, - iconColor: Colors.red, + iconColor: _getTrafficIconColor( + context, + TrafficFactor.high, + ), ), ], ), @@ -315,12 +332,40 @@ class _SettingsScreenState extends State _buildSettingsCard( context, children: [ - _buildAboutTile( - context, - title: 'PH Fare Calculator', - subtitle: 'Version $_appVersion (Build $_buildNumber)', - icon: Icons.calculate_rounded, + // App logo and name + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const AppLogoWidget( + size: AppLogoSize.medium, + showShadow: false, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'PH Fare Calculator', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'Version $_appVersion (Build $_buildNumber)', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), ), + const Divider(height: 1, indent: 16, endIndent: 16), const Divider(height: 1, indent: 56), _buildAboutTile( context, @@ -333,6 +378,16 @@ class _SettingsScreenState extends State applicationVersion: '$_appVersion+$_buildNumber', ), ), + const Divider(height: 1, indent: 56), + _buildAboutTile( + context, + title: AppLocalizations.of(context)!.sourceCodeTitle, + subtitle: AppLocalizations.of( + context, + )!.sourceCodeSubtitle, + icon: Icons.code_rounded, + onTap: () => _launchRepositoryUrl(), + ), ], ), const SizedBox(height: 32), @@ -393,6 +448,22 @@ class _SettingsScreenState extends State ); } + /// Gets theme-aware traffic icon color based on traffic factor. + Color _getTrafficIconColor(BuildContext context, TrafficFactor factor) { + final isDark = Theme.of(context).brightness == Brightness.dark; + switch (factor) { + case TrafficFactor.low: + // Green - darker in light mode, lighter pastel in dark mode + return isDark ? const Color(0xFFA8D5AA) : const Color(0xFF2E7D32); + case TrafficFactor.medium: + // Orange - darker in light mode, lighter pastel in dark mode + return isDark ? const Color(0xFFE8CFA8) : const Color(0xFFE65100); + case TrafficFactor.high: + // Red - darker in light mode, lighter pastel in dark mode + return isDark ? const Color(0xFFE8AEAB) : const Color(0xFFC62828); + } + } + /// Builds a switch tile with icon. Widget _buildSwitchTile( BuildContext context, { @@ -430,12 +501,111 @@ class _SettingsScreenState extends State ), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 24), ), - activeTrackColor: colorScheme.primary.withValues(alpha: 0.5), + activeTrackColor: colorScheme.primary, + activeThumbColor: colorScheme.onPrimary, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), ), ); } + /// Builds a theme mode selection tile with segmented button. + Widget _buildThemeModeTile(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final l10n = AppLocalizations.of(context)!; + + String getThemeModeLabel(String mode) { + switch (mode) { + case 'light': + return l10n.themeModeLight; + case 'dark': + return l10n.themeModeDark; + case 'system': + default: + return l10n.themeModeSystem; + } + } + + return Semantics( + label: l10n.themeModeTitle, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.brightness_6_rounded, + color: colorScheme.onPrimaryContainer, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.themeModeTitle, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + l10n.themeModeSubtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: SegmentedButton( + segments: [ + ButtonSegment( + value: 'system', + label: Text(getThemeModeLabel('system')), + icon: const Icon(Icons.phone_android_rounded, size: 18), + ), + ButtonSegment( + value: 'light', + label: Text(getThemeModeLabel('light')), + icon: const Icon(Icons.light_mode_rounded, size: 18), + ), + ButtonSegment( + value: 'dark', + label: Text(getThemeModeLabel('dark')), + icon: const Icon(Icons.dark_mode_rounded, size: 18), + ), + ], + selected: {_themeMode}, + onSelectionChanged: (Set newSelection) async { + final newMode = newSelection.first; + setState(() => _themeMode = newMode); + await _settingsService.setThemeMode(newMode); + }, + style: ButtonStyle(visualDensity: VisualDensity.compact), + ), + ), + ], + ), + ), + ); + } + /// Builds a language selection tile. Widget _buildLanguageTile(BuildContext context) { final theme = Theme.of(context); @@ -860,7 +1030,8 @@ class _SettingsScreenState extends State }, contentPadding: EdgeInsets.zero, dense: true, - activeTrackColor: colorScheme.primary.withValues(alpha: 0.5), + activeTrackColor: colorScheme.primary, + activeThumbColor: colorScheme.onPrimary, ); }), ], @@ -916,6 +1087,14 @@ class _SettingsScreenState extends State ); } + /// Launches the GitHub repository URL in an external browser. + Future _launchRepositoryUrl() async { + final uri = Uri.parse(AppConstants.repositoryUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + IconData _getIconForCategory(String category) { switch (category.toLowerCase()) { case 'road': diff --git a/lib/src/presentation/screens/splash_screen.dart b/lib/src/presentation/screens/splash_screen.dart index fe4fe57..c17c8f6 100644 --- a/lib/src/presentation/screens/splash_screen.dart +++ b/lib/src/presentation/screens/splash_screen.dart @@ -2,13 +2,14 @@ import 'package:flutter/material.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:path_provider/path_provider.dart'; import 'package:ph_fare_calculator/src/core/di/injection.dart'; -import 'package:ph_fare_calculator/src/services/connectivity/connectivity_service.dart'; import 'package:ph_fare_calculator/src/models/fare_formula.dart'; import 'package:ph_fare_calculator/src/models/fare_result.dart'; import 'package:ph_fare_calculator/src/models/saved_route.dart'; import 'package:ph_fare_calculator/src/presentation/screens/main_screen.dart'; import 'package:ph_fare_calculator/src/presentation/screens/onboarding_screen.dart'; +import 'package:ph_fare_calculator/src/presentation/widgets/app_logo_widget.dart'; import 'package:ph_fare_calculator/src/repositories/fare_repository.dart'; +import 'package:ph_fare_calculator/src/services/connectivity/connectivity_service.dart'; import 'package:ph_fare_calculator/src/services/settings_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -38,10 +39,6 @@ class _SplashScreenState extends State late final Animation _textOpacity; late final Animation _textSlide; - // Brand colors from AppTheme - static const Color _primaryBlue = Color(0xFF0038A8); - static const Color _secondaryYellow = Color(0xFFFCD116); - @override void initState() { super.initState(); @@ -149,7 +146,7 @@ class _SplashScreenState extends State // 4. Settings final settingsService = getIt(); - await settingsService.getHighContrastEnabled(); + await settingsService.getThemeMode(); // 5. Check Onboarding final prefs = await SharedPreferences.getInstance(); @@ -190,56 +187,62 @@ class _SplashScreenState extends State void _showErrorScreen(Object error) { Navigator.of(context).pushReplacement( MaterialPageRoute( - builder: (context) => Scaffold( - body: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [_primaryBlue.withValues(alpha: 0.1), Colors.white], + builder: (context) { + final colorScheme = Theme.of(context).colorScheme; + return Scaffold( + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + colorScheme.primary.withValues(alpha: 0.1), + colorScheme.surface, + ], + ), ), - ), - child: Center( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: const Color(0xFFCE1126).withValues(alpha: 0.1), - shape: BoxShape.circle, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.error_outline_rounded, + size: 64, + color: colorScheme.error, + ), ), - child: const Icon( - Icons.error_outline_rounded, - size: 64, - color: Color(0xFFCE1126), + const SizedBox(height: 24), + Text( + 'Initialization Failed', + style: Theme.of(context).textTheme.headlineMedium + ?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), ), - ), - const SizedBox(height: 24), - Text( - 'Initialization Failed', - style: Theme.of(context).textTheme.headlineMedium - ?.copyWith( - fontWeight: FontWeight.bold, - color: const Color(0xFF1A1C1E), - ), - ), - const SizedBox(height: 16), - Text( - 'Error: $error', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: const Color(0xFF757575), + const SizedBox(height: 16), + Text( + 'Error: $error', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), - ), - ], + ], + ), ), ), ), - ), - ), + ); + }, ), ); } @@ -259,8 +262,8 @@ class _SplashScreenState extends State begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ - _primaryBlue, - _primaryBlue.withValues(alpha: 0.85), + colorScheme.primary, + colorScheme.primary.withValues(alpha: 0.85), colorScheme.primary.withValues(alpha: 0.7), ], stops: const [0.0, 0.5, 1.0], @@ -292,69 +295,32 @@ class _SplashScreenState extends State } Widget _buildAnimatedLogo() { - return Semantics( - label: 'Application logo', - child: AnimatedBuilder( - animation: _logoController, - builder: (context, child) { - return Transform.scale( - scale: _logoScale.value, - child: Opacity(opacity: _logoOpacity.value, child: child), - ); - }, - child: Container( - width: 140, - height: 140, - decoration: BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.2), - blurRadius: 24, - offset: const Offset(0, 8), - ), - ], - ), - child: Center( - child: Container( - width: 100, - height: 100, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [_primaryBlue, _primaryBlue.withValues(alpha: 0.8)], - ), - shape: BoxShape.circle, - ), - child: const Center( - child: Icon( - Icons.directions_bus_rounded, - size: 56, - color: Colors.white, - ), - ), - ), - ), - ), - ), + return AnimatedBuilder( + animation: _logoController, + builder: (context, child) { + return Transform.scale( + scale: _logoScale.value, + child: Opacity(opacity: _logoOpacity.value, child: child), + ); + }, + child: const AppLogoWidget(size: AppLogoSize.large, showShadow: true), ); } Widget _buildAnimatedTitle() { + final colorScheme = Theme.of(context).colorScheme; return Semantics( label: 'PH Fare Calculator', child: SlideTransition( position: _textSlide, child: FadeTransition( opacity: _textOpacity, - child: const Text( + child: Text( 'PH Fare Calculator', style: TextStyle( fontSize: 28, fontWeight: FontWeight.bold, - color: Colors.white, + color: colorScheme.onPrimary, letterSpacing: 0.5, ), ), @@ -364,6 +330,7 @@ class _SplashScreenState extends State } Widget _buildAnimatedTagline() { + final colorScheme = Theme.of(context).colorScheme; return Semantics( label: 'Know your fare before you ride', child: SlideTransition( @@ -375,7 +342,7 @@ class _SplashScreenState extends State style: TextStyle( fontSize: 16, fontWeight: FontWeight.w400, - color: Colors.white.withValues(alpha: 0.9), + color: colorScheme.onPrimary.withValues(alpha: 0.9), letterSpacing: 0.3, ), ), @@ -385,6 +352,7 @@ class _SplashScreenState extends State } Widget _buildLoadingIndicator() { + final colorScheme = Theme.of(context).colorScheme; return Semantics( label: 'Loading application', child: Column( @@ -397,9 +365,9 @@ class _SplashScreenState extends State builder: (context, child) { return LinearProgressIndicator( value: null, - backgroundColor: Colors.white.withValues(alpha: 0.3), - valueColor: const AlwaysStoppedAnimation( - _secondaryYellow, + backgroundColor: colorScheme.onPrimary.withValues(alpha: 0.3), + valueColor: AlwaysStoppedAnimation( + colorScheme.secondary, ), minHeight: 4, borderRadius: BorderRadius.circular(2), @@ -412,7 +380,7 @@ class _SplashScreenState extends State 'Loading...', style: TextStyle( fontSize: 14, - color: Colors.white.withValues(alpha: 0.8), + color: colorScheme.onPrimary.withValues(alpha: 0.8), fontWeight: FontWeight.w500, ), ), diff --git a/lib/src/presentation/widgets/app_logo_widget.dart b/lib/src/presentation/widgets/app_logo_widget.dart new file mode 100644 index 0000000..83e1735 --- /dev/null +++ b/lib/src/presentation/widgets/app_logo_widget.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; + +/// Size variants for the app logo widget. +enum AppLogoSize { + /// Small size for app bar (40x40 outer, 28x28 inner, 16px icon) + small, + + /// Medium size for general use (80x80 outer, 56x56 inner, 32px icon) + medium, + + /// Large size for splash/onboarding (140x140 outer, 100x100 inner, 56px icon) + large, +} + +/// A reusable logo widget that displays the PH Fare Calculator bus icon +/// with a circular gradient container matching the splash/onboarding screens. +/// +/// The widget supports different sizes through [AppLogoSize] and maintains +/// consistent proportions and styling across all sizes. +class AppLogoWidget extends StatelessWidget { + /// Creates an [AppLogoWidget] with the specified size. + const AppLogoWidget({ + super.key, + this.size = AppLogoSize.large, + this.showShadow = true, + }); + + /// The size variant of the logo. + final AppLogoSize size; + + /// Whether to show the drop shadow. Defaults to true. + final bool showShadow; + + @override + Widget build(BuildContext context) { + final dimensions = _getDimensions(); + final colorScheme = Theme.of(context).colorScheme; + + // The logo uses the brand primary color consistently + // We use colorScheme.primary which is derived from the same brand color + final brandColor = colorScheme.primary; + + return Semantics( + label: 'PH Fare Calculator logo', + child: Container( + width: dimensions.outerSize, + height: dimensions.outerSize, + decoration: BoxDecoration( + color: colorScheme.surface, + shape: BoxShape.circle, + boxShadow: showShadow + ? [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.2), + blurRadius: dimensions.shadowBlur, + offset: Offset(0, dimensions.shadowOffset), + ), + ] + : null, + ), + child: Center( + child: Container( + width: dimensions.innerSize, + height: dimensions.innerSize, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [brandColor, brandColor.withValues(alpha: 0.8)], + ), + shape: BoxShape.circle, + ), + child: Center( + child: Icon( + Icons.directions_bus_rounded, + size: dimensions.iconSize, + color: colorScheme.onPrimary, + ), + ), + ), + ), + ), + ); + } + + _LogoDimensions _getDimensions() { + switch (size) { + case AppLogoSize.small: + return const _LogoDimensions( + outerSize: 40, + innerSize: 28, + iconSize: 16, + shadowBlur: 8, + shadowOffset: 2, + ); + case AppLogoSize.medium: + return const _LogoDimensions( + outerSize: 80, + innerSize: 56, + iconSize: 32, + shadowBlur: 16, + shadowOffset: 4, + ); + case AppLogoSize.large: + return const _LogoDimensions( + outerSize: 140, + innerSize: 100, + iconSize: 56, + shadowBlur: 24, + shadowOffset: 8, + ); + } + } +} + +/// Internal class to hold dimension values for each size variant. +class _LogoDimensions { + const _LogoDimensions({ + required this.outerSize, + required this.innerSize, + required this.iconSize, + required this.shadowBlur, + required this.shadowOffset, + }); + + final double outerSize; + final double innerSize; + final double iconSize; + final double shadowBlur; + final double shadowOffset; +} diff --git a/lib/src/presentation/widgets/fare_result_card.dart b/lib/src/presentation/widgets/fare_result_card.dart index 95e8f56..c9e9799 100644 --- a/lib/src/presentation/widgets/fare_result_card.dart +++ b/lib/src/presentation/widgets/fare_result_card.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; + +import '../../core/theme/transit_colors.dart'; import '../../models/fare_result.dart'; /// A modern, accessible fare result card widget. @@ -34,9 +36,10 @@ class FareResultCard extends StatelessWidget { /// Returns the status color based on indicator level. Color _getStatusColor(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final transitColors = Theme.of(context).extension(); switch (indicatorLevel) { case IndicatorLevel.standard: - return const Color(0xFF4CAF50); // Green + return transitColors?.standardFare ?? colorScheme.primary; case IndicatorLevel.peak: return colorScheme.secondary; // Yellow from theme case IndicatorLevel.touristTrap: 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 2ca31ed..33cecdc 100644 --- a/lib/src/presentation/widgets/main_screen/location_input_section.dart +++ b/lib/src/presentation/widgets/main_screen/location_input_section.dart @@ -30,10 +30,34 @@ 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; + static const double _originCircleSize = 12; + static const double _destinationIconSize = 16; + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + // Total height of both fields + gap + const totalFieldsHeight = _inputFieldHeight * 2 + _fieldGap; + + // 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; + return Card( elevation: 0, shape: RoundedRectangleBorder( @@ -42,88 +66,104 @@ class LocationInputSection extends StatelessWidget { ), child: Padding( padding: const EdgeInsets.all(16), - child: Column( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Route Indicator - Column( - children: [ - Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: colorScheme.primary, - shape: BoxShape.circle, - ), + // Route Indicator - fixed height column with calculated positions + SizedBox( + height: totalFieldsHeight, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + // Spacer to center origin circle with first field + SizedBox(height: (_inputFieldHeight - _originCircleSize) / 2), + // Origin circle indicator + Container( + width: _originCircleSize, + height: _originCircleSize, + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, ), - Container( - width: 2, - height: 48, - color: colorScheme.outlineVariant, + ), + // Connecting line + Container( + width: 2, + height: lineHeight, + color: colorScheme.outlineVariant, + ), + // Destination pin indicator + Icon( + Icons.location_on, + size: _destinationIconSize, + color: colorScheme.tertiary, + ), + ], + ), + ), + const SizedBox(width: 12), + // Input Fields + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: _inputFieldHeight, + child: _LocationField( + label: 'Origin', + controller: originController, + isOrigin: true, + isLoadingLocation: isLoadingLocation, + onSearchLocations: (query) => + onSearchLocations(query, true), + onLocationSelected: onOriginSelected, + onUseCurrentLocation: onUseCurrentLocation, + onOpenMapPicker: () => onOpenMapPicker(true), ), - Icon( - Icons.location_on, - size: 16, - color: colorScheme.tertiary, + ), + const SizedBox(height: _fieldGap), + SizedBox( + height: _inputFieldHeight, + child: _LocationField( + label: 'Destination', + controller: destinationController, + isOrigin: false, + isLoadingLocation: false, + onSearchLocations: (query) => + onSearchLocations(query, false), + onLocationSelected: onDestinationSelected, + onUseCurrentLocation: null, + onOpenMapPicker: () => onOpenMapPicker(false), ), - ], - ), - const SizedBox(width: 12), - // Input Fields - Expanded( - child: Column( - children: [ - _LocationField( - label: 'Origin', - controller: originController, - isOrigin: true, - isLoadingLocation: isLoadingLocation, - onSearchLocations: (query) => - onSearchLocations(query, true), - onLocationSelected: onOriginSelected, - onUseCurrentLocation: onUseCurrentLocation, - onOpenMapPicker: () => onOpenMapPicker(true), - ), - const SizedBox(height: 12), - _LocationField( - label: 'Destination', - controller: destinationController, - isOrigin: false, - isLoadingLocation: false, - onSearchLocations: (query) => - onSearchLocations(query, false), - onLocationSelected: onDestinationSelected, - onUseCurrentLocation: null, - onOpenMapPicker: () => onOpenMapPicker(false), - ), - ], ), - ), - // Swap Button - Padding( - padding: const EdgeInsets.only(left: 8, top: 20), - child: Semantics( - label: 'Swap origin and destination', - button: true, - child: IconButton( - icon: Icon( - Icons.swap_vert_rounded, - color: colorScheme.primary, + ], + ), + ), + const SizedBox(width: 8), + // Swap Button - centered vertically relative to both input fields + SizedBox( + height: totalFieldsHeight, + 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, ), - onPressed: onSwapLocations, - style: IconButton.styleFrom( - backgroundColor: colorScheme.primaryContainer - .withValues(alpha: 0.3), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), ), ), ), - ], + ), ), ], ), diff --git a/lib/src/presentation/widgets/main_screen/main_screen_app_bar.dart b/lib/src/presentation/widgets/main_screen/main_screen_app_bar.dart index 48c06e3..3fb0e33 100644 --- a/lib/src/presentation/widgets/main_screen/main_screen_app_bar.dart +++ b/lib/src/presentation/widgets/main_screen/main_screen_app_bar.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../../l10n/app_localizations.dart'; import '../../screens/offline_menu_screen.dart'; import '../../screens/settings_screen.dart'; +import '../app_logo_widget.dart'; /// Modern app bar widget for the main screen. class MainScreenAppBar extends StatelessWidget { @@ -17,6 +18,8 @@ class MainScreenAppBar extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ + const AppLogoWidget(size: AppLogoSize.small, showShadow: false), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/src/presentation/widgets/map_selection_widget.dart b/lib/src/presentation/widgets/map_selection_widget.dart index da1195e..14c55fc 100644 --- a/lib/src/presentation/widgets/map_selection_widget.dart +++ b/lib/src/presentation/widgets/map_selection_widget.dart @@ -3,6 +3,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import '../../core/di/injection.dart'; +import '../../core/theme/transit_colors.dart'; import '../../services/offline/offline_map_service.dart'; /// A modern, accessible map selection widget. @@ -257,20 +258,35 @@ class _MapSelectionWidgetState extends State } /// Builds the tile layer, using cached tiles when available. + /// The tile style automatically adjusts based on the current theme (light/dark). + /// For dark mode, CartoDB Voyager tiles are inverted using ColorFiltered. Widget _buildTileLayer() { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + Widget tileLayer; + if (widget.useCachedTiles) { try { final offlineMapService = getIt(); - return offlineMapService.getCachedTileLayer(); + tileLayer = offlineMapService.getThemedCachedTileLayer( + isDarkMode: isDarkMode, + ); } catch (_) { // Fall back to network tiles if service not initialized + tileLayer = OfflineMapService.getNetworkTileLayer( + isDarkMode: isDarkMode, + ); } + } else { + tileLayer = OfflineMapService.getNetworkTileLayer(isDarkMode: isDarkMode); } - return TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'com.ph_fare_calculator', - ); + // Apply color inversion for dark mode to create dark appearance from Voyager tiles + if (isDarkMode) { + return OfflineMapService.wrapWithDarkModeFilter(tileLayer); + } + + return tileLayer; } List _buildMarkers(BuildContext context) { @@ -324,13 +340,14 @@ class _MapSelectionWidgetState extends State } Widget _buildOriginMarker(ColorScheme colorScheme) { + final transitColors = Theme.of(context).extension(); return Container( decoration: BoxDecoration( shape: BoxShape.circle, - color: Colors.white, + color: colorScheme.surface, boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.2), + color: colorScheme.shadow.withValues(alpha: 0.2), blurRadius: 8, offset: const Offset(0, 2), ), @@ -340,10 +357,14 @@ class _MapSelectionWidgetState extends State child: Container( decoration: BoxDecoration( shape: BoxShape.circle, - color: const Color(0xFF4CAF50), - border: Border.all(color: Colors.white, width: 2), + color: transitColors?.originMarker ?? colorScheme.primary, + border: Border.all(color: colorScheme.surface, width: 2), + ), + child: Icon( + Icons.my_location, + color: transitColors?.onOriginMarker ?? colorScheme.onPrimary, + size: 20, ), - child: const Icon(Icons.my_location, color: Colors.white, size: 20), ), ); } @@ -365,7 +386,11 @@ class _MapSelectionWidgetState extends State ], ), padding: const EdgeInsets.all(8), - child: const Icon(Icons.location_on, color: Colors.white, size: 20), + child: Icon( + Icons.location_on, + color: colorScheme.onTertiary, + size: 20, + ), ), // Pin point Container( diff --git a/lib/src/services/offline/offline_map_service.dart b/lib/src/services/offline/offline_map_service.dart index c042c33..aafe8dc 100644 --- a/lib/src/services/offline/offline_map_service.dart +++ b/lib/src/services/offline/offline_map_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart' as fmtc; import 'package:hive/hive.dart'; @@ -532,21 +533,92 @@ class OfflineMapService { await _regionsBox?.clear(); } + /// CartoDB Voyager tile URL - used for both light and dark mode + /// Voyager has excellent road visibility and supports zoom levels 0-20 + /// For dark mode, we apply a color inversion filter at the widget level + static const String _voyagerTileUrl = + 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'; + + /// Subdomains for CartoDB tile servers (load balancing) + static const List _cartoSubdomains = ['a', 'b', 'c', 'd']; + + /// Color inversion matrix for dark mode + /// This inverts the light Voyager tiles to create a dark appearance with visible roads + static const ColorFilter darkModeInvertFilter = ColorFilter.matrix([ + -1, + 0, + 0, + 0, + 255, + 0, + -1, + 0, + 0, + 255, + 0, + 0, + -1, + 0, + 255, + 0, + 0, + 0, + 1, + 0, + ]); + + /// Wraps a widget with the dark mode color inversion filter + /// + /// Use this to wrap TileLayer widgets when displaying maps in dark mode. + /// The filter inverts the light Voyager tiles to create a dark appearance. + static Widget wrapWithDarkModeFilter(Widget child) { + return ColorFiltered(colorFilter: darkModeInvertFilter, child: child); + } + /// Gets a tile layer that uses the FMTC cache. /// /// Falls back to network tiles when cache misses occur. + /// Uses light mode tiles by default. TileLayer getCachedTileLayer() { + return getThemedCachedTileLayer(isDarkMode: false); + } + + /// Gets a theme-aware tile layer that uses the FMTC cache. + /// + /// Uses CartoDB Voyager tiles for both light and dark mode. + /// For dark mode, the calling widget should wrap this with [wrapWithDarkModeFilter]. + /// Falls back to network tiles when cache misses occur. + TileLayer getThemedCachedTileLayer({required bool isDarkMode}) { _ensureInitialized(); + // Both light and dark mode use Voyager tiles + // Dark mode applies color inversion at the widget level return TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + urlTemplate: _voyagerTileUrl, + subdomains: _cartoSubdomains, userAgentPackageName: 'com.ph_fare_calculator', + maxZoom: 20, // Voyager supports up to zoom 20 tileProvider: fmtc.FMTCTileProvider( stores: {_storeName: fmtc.BrowseStoreStrategy.readUpdateCreate}, ), ); } + /// Gets a tile layer without FMTC caching (for fallback scenarios). + /// + /// Uses CartoDB Voyager tiles for both light and dark mode. + /// For dark mode, the calling widget should wrap this with [wrapWithDarkModeFilter]. + static TileLayer getNetworkTileLayer({required bool isDarkMode}) { + // Both light and dark mode use Voyager tiles + // Dark mode applies color inversion at the widget level + return TileLayer( + urlTemplate: _voyagerTileUrl, + subdomains: _cartoSubdomains, + userAgentPackageName: 'com.ph_fare_calculator', + maxZoom: 20, // Voyager supports up to zoom 20 + ); + } + /// Checks if a point is within any downloaded region. bool isPointCached(LatLng point) { for (final region in _allRegions) { diff --git a/lib/src/services/settings_service.dart b/lib/src/services/settings_service.dart index d3fb2fb..5ed51a4 100644 --- a/lib/src/services/settings_service.dart +++ b/lib/src/services/settings_service.dart @@ -10,7 +10,7 @@ enum TrafficFactor { low, medium, high } class SettingsService { static const String _keyProvincialMode = 'isProvincialModeEnabled'; static const String _keyTrafficFactor = 'trafficFactor'; - static const String _keyHighContrastEnabled = 'isHighContrastEnabled'; + static const String _keyThemeMode = 'themeMode'; static const String _keyLocale = 'locale'; static const String _keyUserDiscountType = 'user_discount_type'; static const String _keyHasSetDiscountType = 'has_set_discount_type'; @@ -19,7 +19,9 @@ class SettingsService { static const String _keyLastLongitude = 'last_known_longitude'; static const String _keyLastLocationName = 'last_known_location_name'; - static final ValueNotifier highContrastNotifier = ValueNotifier(false); + /// Notifier for theme mode changes. Values: 'system', 'light', 'dark' + /// Default is 'light' for first-time users. + static final ValueNotifier themeModeNotifier = ValueNotifier('light'); static final ValueNotifier localeNotifier = ValueNotifier( const Locale('en'), ); @@ -60,19 +62,31 @@ class SettingsService { await prefs.setString(_keyTrafficFactor, factor.name); } - Future getHighContrastEnabled() async { + /// Get the theme mode preference. Returns 'system', 'light', or 'dark'. + /// Default is 'light' for first-time users. + Future getThemeMode() async { final prefs = await SharedPreferences.getInstance(); - final value = prefs.getBool(_keyHighContrastEnabled) ?? false; - if (highContrastNotifier.value != value) { - highContrastNotifier.value = value; + final value = prefs.getString(_keyThemeMode) ?? 'light'; + // Validate the value + if (value != 'system' && value != 'light' && value != 'dark') { + return 'system'; + } + if (themeModeNotifier.value != value) { + themeModeNotifier.value = value; } return value; } - Future setHighContrastEnabled(bool value) async { + /// Set the theme mode preference. + /// @param mode: 'system', 'light', or 'dark' + Future setThemeMode(String mode) async { + // Validate the mode + if (mode != 'system' && mode != 'light' && mode != 'dark') { + mode = 'system'; + } final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_keyHighContrastEnabled, value); - highContrastNotifier.value = value; + await prefs.setString(_keyThemeMode, mode); + themeModeNotifier.value = mode; } Future getLocale() async { diff --git a/pubspec.lock b/pubspec.lock index 66dc4f4..dc09e11 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.4.1" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" args: dependency: transitive description: @@ -121,6 +129,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -262,6 +278,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" flutter_lints: dependency: "direct dev" description: @@ -429,6 +453,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "51555e36056541237b15b57afc31a0f53d4f9aefd9bd00873a6dc0090e54e332" + url: "https://pub.dev" + source: hosted + version: "4.6.0" injectable: dependency: "direct main" description: @@ -629,6 +661,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: "direct main" description: @@ -717,6 +765,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" proj4dart: dependency: transitive description: @@ -922,6 +978,70 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + url: "https://pub.dev" + source: hosted + version: "6.3.6" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: transitive description: @@ -978,6 +1098,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" wkt_parser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3ef6a11..ae2b328 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.0.0+2 +version: 2.1.0+3 environment: sdk: ^3.9.2 @@ -57,6 +57,12 @@ dependencies: # Path utilities path: ^1.9.0 + # Package info for version display + package_info_plus: ^8.0.0 + + # URL launcher for opening external links + url_launcher: ^6.2.5 + dev_dependencies: flutter_test: sdk: flutter @@ -72,6 +78,9 @@ dev_dependencies: hive_generator: ^2.0.1 mockito: ^5.4.4 + # App launcher icon generator + flutter_launcher_icons: ^0.14.3 + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -114,4 +123,24 @@ flutter: # weight: 700 # # For details regarding fonts from package dependencies, see - # https://flutter.dev/to/font-from-package \ No newline at end of file + # https://flutter.dev/to/font-from-package + +# Flutter Launcher Icons configuration +# Run: flutter pub run flutter_launcher_icons +flutter_launcher_icons: + # Android configuration + android: true + ios: true + + # Path to the source icon (1024x1024 recommended) + image_path: "assets/icons/app_icon.png" + + # Remove alpha channel for iOS (required for App Store) + remove_alpha_ios: true + + # Android adaptive icon configuration + adaptive_icon_background: "#0038A8" # Philippine flag blue + adaptive_icon_foreground: "assets/icons/app_icon_foreground.png" + + # Minimum SDK version for adaptive icons + min_sdk_android: 21 \ No newline at end of file diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index 000d1d8..973be55 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -71,7 +71,7 @@ class MockRoutingService implements RoutingService { class MockSettingsService implements SettingsService { bool provincialMode = false; TrafficFactor trafficFactor = TrafficFactor.medium; - bool highContrast = false; + String themeMode = 'system'; DiscountType discountType = DiscountType.standard; // Replicate static behavior instance-wise for injection @@ -92,11 +92,11 @@ class MockSettingsService implements SettingsService { } @override - Future getHighContrastEnabled() async => highContrast; + Future getThemeMode() async => themeMode; @override - Future setHighContrastEnabled(bool value) async { - highContrast = value; + Future setThemeMode(String mode) async { + themeMode = mode; } @override diff --git a/test/screens/onboarding_localization_test.dart b/test/screens/onboarding_localization_test.dart index ee4fc21..e87ff59 100644 --- a/test/screens/onboarding_localization_test.dart +++ b/test/screens/onboarding_localization_test.dart @@ -39,12 +39,12 @@ class FakeSettingsService implements SettingsService { trafficFactor = factor; } - bool highContrast = false; + String themeMode = 'system'; @override - Future getHighContrastEnabled() async => highContrast; + Future getThemeMode() async => themeMode; @override - Future setHighContrastEnabled(bool value) async { - highContrast = value; + Future setThemeMode(String mode) async { + themeMode = mode; } DiscountType discountType = DiscountType.standard; diff --git a/test/screens/settings_screen_test.dart b/test/screens/settings_screen_test.dart index 3e024fa..fb0c6c9 100644 --- a/test/screens/settings_screen_test.dart +++ b/test/screens/settings_screen_test.dart @@ -56,14 +56,19 @@ void main() { expect(find.text('Settings'), findsOneWidget); expect(find.text('Provincial Mode'), findsOneWidget); - expect(find.text('High Contrast Mode'), findsOneWidget); + expect(find.text('Theme'), findsOneWidget); expect(find.text('Traffic Factor'), findsOneWidget); - // Scroll down to see bottom sections - await tester.drag(find.byType(ListView), const Offset(0, -500)); + // Scroll down to see Passenger Type section + await tester.drag(find.byType(ListView), const Offset(0, -400)); await tester.pumpAndSettle(); expect(find.text('Passenger Type'), findsOneWidget); + + // Scroll down more to see Transport Modes section + await tester.drag(find.byType(ListView), const Offset(0, -400)); + await tester.pumpAndSettle(); + expect(find.text('Transport Modes'), findsOneWidget); }); @@ -80,16 +85,27 @@ void main() { await tester.pumpAndSettle(); expect(mockSettingsService.provincialMode, true); + }); - // 2. Toggle High Contrast - final highContrastSwitch = find.widgetWithText( - SwitchListTile, - 'High Contrast Mode', - ); - await tester.tap(highContrastSwitch); + testWidgets('Theme mode selector updates settings service', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // Find and tap the Dark theme segment + final darkSegment = find.text('Dark'); + await tester.tap(darkSegment); + await tester.pumpAndSettle(); + + expect(mockSettingsService.themeMode, 'dark'); + + // Tap the Light theme segment + final lightSegment = find.text('Light'); + await tester.tap(lightSegment); await tester.pumpAndSettle(); - expect(mockSettingsService.highContrast, true); + expect(mockSettingsService.themeMode, 'light'); }); testWidgets('Traffic Factor selection updates settings service', ( @@ -129,8 +145,10 @@ void main() { await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(const Duration(seconds: 1)); - // Scroll down to see Transport Modes section - await tester.drag(find.byType(ListView), const Offset(0, -500)); + // Scroll down to see Transport Modes section (multiple scrolls needed) + await tester.drag(find.byType(ListView), const Offset(0, -400)); + await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -400)); await tester.pumpAndSettle(); // The Phase 5 refactor groups modes by category (Road, Rail, Water) @@ -138,4 +156,19 @@ void main() { // With Jeepney formula, we should see the Road category expect(find.text('Road'), findsOneWidget); }); + + testWidgets('About section contains Source Code link to GitHub', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // Scroll down to see About section + await tester.drag(find.byType(ListView), const Offset(0, -800)); + await tester.pumpAndSettle(); + + // Verify the Source Code tile exists + expect(find.text('Source Code'), findsOneWidget); + expect(find.text('View on GitHub'), findsOneWidget); + }); } diff --git a/test/services/settings_service_test.dart b/test/services/settings_service_test.dart index 596e759..3f103ef 100644 --- a/test/services/settings_service_test.dart +++ b/test/services/settings_service_test.dart @@ -14,7 +14,8 @@ void main() { test('Default values are correct', () async { expect(await settingsService.getProvincialMode(), false); expect(await settingsService.getTrafficFactor(), TrafficFactor.medium); - expect(await settingsService.getHighContrastEnabled(), false); + // Default theme is 'light' for first-time users (changed from 'system') + expect(await settingsService.getThemeMode(), 'light'); }); test('Provincial mode is saved and retrieved', () async { @@ -33,10 +34,23 @@ void main() { expect(await settingsService.getTrafficFactor(), TrafficFactor.low); }); - test('High contrast is saved and retrieved', () async { - await settingsService.setHighContrastEnabled(true); - expect(await settingsService.getHighContrastEnabled(), true); - expect(SettingsService.highContrastNotifier.value, true); + test('Theme mode is saved and retrieved', () async { + await settingsService.setThemeMode('dark'); + expect(await settingsService.getThemeMode(), 'dark'); + expect(SettingsService.themeModeNotifier.value, 'dark'); + + await settingsService.setThemeMode('light'); + expect(await settingsService.getThemeMode(), 'light'); + expect(SettingsService.themeModeNotifier.value, 'light'); + + await settingsService.setThemeMode('system'); + expect(await settingsService.getThemeMode(), 'system'); + expect(SettingsService.themeModeNotifier.value, 'system'); + }); + + test('Invalid theme mode defaults to system', () async { + await settingsService.setThemeMode('invalid'); + expect(await settingsService.getThemeMode(), 'system'); }); test('Last location returns null when not previously saved', () async { diff --git a/test/widgets/app_logo_widget_test.dart b/test/widgets/app_logo_widget_test.dart new file mode 100644 index 0000000..220b00d --- /dev/null +++ b/test/widgets/app_logo_widget_test.dart @@ -0,0 +1,484 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ph_fare_calculator/src/presentation/widgets/app_logo_widget.dart'; + +void main() { + // Helper function to wrap widget in MaterialApp for testing + Widget createWidgetUnderTest({ + AppLogoSize size = AppLogoSize.large, + bool showShadow = true, + }) { + return MaterialApp( + home: Scaffold( + body: Center( + child: AppLogoWidget(size: size, showShadow: showShadow), + ), + ), + ); + } + + group('AppLogoWidget', () { + // ============================================================ + // Happy Path Tests + // ============================================================ + group('Happy Path - Default rendering', () { + testWidgets('renders without errors with default parameters', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(createWidgetUnderTest()); + + expect(find.byType(AppLogoWidget), findsOneWidget); + }); + + testWidgets('renders the bus icon', (WidgetTester tester) async { + await tester.pumpWidget(createWidgetUnderTest()); + + expect(find.byIcon(Icons.directions_bus_rounded), findsOneWidget); + }); + + testWidgets('default size is large', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: Center(child: AppLogoWidget())), + ), + ); + + // Find the outer container (first Container which is the AppLogoWidget's root) + final appLogoWidget = tester.widget( + find.byType(AppLogoWidget), + ); + expect(appLogoWidget.size, equals(AppLogoSize.large)); + }); + + testWidgets('default showShadow is true', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: Center(child: AppLogoWidget())), + ), + ); + + final appLogoWidget = tester.widget( + find.byType(AppLogoWidget), + ); + expect(appLogoWidget.showShadow, isTrue); + }); + }); + + // ============================================================ + // Size Variant Tests + // ============================================================ + group('Size Variants - Small', () { + testWidgets('small size renders correctly', (WidgetTester tester) async { + await tester.pumpWidget(createWidgetUnderTest(size: AppLogoSize.small)); + + expect(find.byType(AppLogoWidget), findsOneWidget); + expect(find.byIcon(Icons.directions_bus_rounded), findsOneWidget); + }); + + testWidgets('small size has correct outer dimensions (40x40)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(createWidgetUnderTest(size: AppLogoSize.small)); + + // Find the Semantics widget which wraps the outer Container + final semanticsFinder = find.bySemanticsLabel( + 'PH Fare Calculator logo', + ); + expect(semanticsFinder, findsOneWidget); + + // Get the Container inside Semantics (the outer container) + final containerFinder = find.descendant( + of: find.byType(AppLogoWidget), + matching: find.byType(Container), + ); + + // The first Container is the outer one + final containers = tester.widgetList(containerFinder); + final outerContainer = containers.first; + + expect(outerContainer.constraints?.maxWidth, equals(40.0)); + expect(outerContainer.constraints?.maxHeight, equals(40.0)); + }); + + testWidgets('small size has correct icon size (16px)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(createWidgetUnderTest(size: AppLogoSize.small)); + + final icon = tester.widget( + find.byIcon(Icons.directions_bus_rounded), + ); + expect(icon.size, equals(16.0)); + }); + }); + + group('Size Variants - Medium', () { + testWidgets('medium size renders correctly', (WidgetTester tester) async { + await tester.pumpWidget( + createWidgetUnderTest(size: AppLogoSize.medium), + ); + + expect(find.byType(AppLogoWidget), findsOneWidget); + expect(find.byIcon(Icons.directions_bus_rounded), findsOneWidget); + }); + + testWidgets('medium size has correct outer dimensions (80x80)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + createWidgetUnderTest(size: AppLogoSize.medium), + ); + + final containerFinder = find.descendant( + of: find.byType(AppLogoWidget), + matching: find.byType(Container), + ); + + final containers = tester.widgetList(containerFinder); + final outerContainer = containers.first; + + expect(outerContainer.constraints?.maxWidth, equals(80.0)); + expect(outerContainer.constraints?.maxHeight, equals(80.0)); + }); + + testWidgets('medium size has correct icon size (32px)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + createWidgetUnderTest(size: AppLogoSize.medium), + ); + + final icon = tester.widget( + find.byIcon(Icons.directions_bus_rounded), + ); + expect(icon.size, equals(32.0)); + }); + }); + + group('Size Variants - Large', () { + testWidgets('large size renders correctly', (WidgetTester tester) async { + await tester.pumpWidget(createWidgetUnderTest(size: AppLogoSize.large)); + + expect(find.byType(AppLogoWidget), findsOneWidget); + expect(find.byIcon(Icons.directions_bus_rounded), findsOneWidget); + }); + + testWidgets('large size has correct outer dimensions (140x140)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(createWidgetUnderTest(size: AppLogoSize.large)); + + final containerFinder = find.descendant( + of: find.byType(AppLogoWidget), + matching: find.byType(Container), + ); + + final containers = tester.widgetList(containerFinder); + final outerContainer = containers.first; + + expect(outerContainer.constraints?.maxWidth, equals(140.0)); + expect(outerContainer.constraints?.maxHeight, equals(140.0)); + }); + + testWidgets('large size has correct icon size (56px)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(createWidgetUnderTest(size: AppLogoSize.large)); + + final icon = tester.widget( + find.byIcon(Icons.directions_bus_rounded), + ); + expect(icon.size, equals(56.0)); + }); + }); + + // ============================================================ + // Shadow Tests + // ============================================================ + group('Shadow Variations', () { + testWidgets('renders with shadow when showShadow is true', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(createWidgetUnderTest(showShadow: true)); + + // Find the outer container + final containerFinder = find.descendant( + of: find.byType(AppLogoWidget), + matching: find.byType(Container), + ); + + final containers = tester.widgetList(containerFinder); + final outerContainer = containers.first; + final decoration = outerContainer.decoration as BoxDecoration; + + expect(decoration.boxShadow, isNotNull); + expect(decoration.boxShadow, isNotEmpty); + }); + + testWidgets('renders without shadow when showShadow is false', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(createWidgetUnderTest(showShadow: false)); + + // Find the outer container + final containerFinder = find.descendant( + of: find.byType(AppLogoWidget), + matching: find.byType(Container), + ); + + final containers = tester.widgetList(containerFinder); + final outerContainer = containers.first; + final decoration = outerContainer.decoration as BoxDecoration; + + expect(decoration.boxShadow, isNull); + }); + + testWidgets('shadow has correct properties for large size', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + createWidgetUnderTest(size: AppLogoSize.large, showShadow: true), + ); + + final containerFinder = find.descendant( + of: find.byType(AppLogoWidget), + matching: find.byType(Container), + ); + + final containers = tester.widgetList(containerFinder); + final outerContainer = containers.first; + final decoration = outerContainer.decoration as BoxDecoration; + + expect(decoration.boxShadow, isNotNull); + expect(decoration.boxShadow!.length, equals(1)); + + final shadow = decoration.boxShadow!.first; + expect(shadow.blurRadius, equals(24.0)); // Large size shadowBlur + expect(shadow.offset, equals(const Offset(0, 8))); // Large shadowOffset + }); + + testWidgets('shadow has correct properties for small size', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + createWidgetUnderTest(size: AppLogoSize.small, showShadow: true), + ); + + final containerFinder = find.descendant( + of: find.byType(AppLogoWidget), + matching: find.byType(Container), + ); + + final containers = tester.widgetList(containerFinder); + final outerContainer = containers.first; + final decoration = outerContainer.decoration as BoxDecoration; + + expect(decoration.boxShadow, isNotNull); + final shadow = decoration.boxShadow!.first; + expect(shadow.blurRadius, equals(8.0)); // Small size shadowBlur + expect(shadow.offset, equals(const Offset(0, 2))); // Small shadowOffset + }); + }); + + // ============================================================ + // Accessibility Tests + // ============================================================ + group('Accessibility', () { + testWidgets('has correct semantics label', (WidgetTester tester) async { + await tester.pumpWidget(createWidgetUnderTest()); + + expect( + find.bySemanticsLabel('PH Fare Calculator logo'), + findsOneWidget, + ); + }); + + testWidgets('semantics label is present for all sizes', ( + WidgetTester tester, + ) async { + // Test small + await tester.pumpWidget(createWidgetUnderTest(size: AppLogoSize.small)); + expect( + find.bySemanticsLabel('PH Fare Calculator logo'), + findsOneWidget, + ); + + // Test medium + await tester.pumpWidget( + createWidgetUnderTest(size: AppLogoSize.medium), + ); + expect( + find.bySemanticsLabel('PH Fare Calculator logo'), + findsOneWidget, + ); + + // Test large + await tester.pumpWidget(createWidgetUnderTest(size: AppLogoSize.large)); + expect( + find.bySemanticsLabel('PH Fare Calculator logo'), + findsOneWidget, + ); + }); + }); + + // ============================================================ + // Visual Styling Tests + // ============================================================ + group('Visual Styling', () { + testWidgets('outer container has theme-aware surface background', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(createWidgetUnderTest()); + + final containerFinder = find.descendant( + of: find.byType(AppLogoWidget), + matching: find.byType(Container), + ); + + final containers = tester.widgetList(containerFinder); + final outerContainer = containers.first; + final decoration = outerContainer.decoration as BoxDecoration; + + // Background uses theme's colorScheme.surface which adapts to light/dark mode + expect(decoration.color, isNotNull); + }); + + testWidgets('outer container has circular shape', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(createWidgetUnderTest()); + + final containerFinder = find.descendant( + of: find.byType(AppLogoWidget), + matching: find.byType(Container), + ); + + final containers = tester.widgetList(containerFinder); + final outerContainer = containers.first; + final decoration = outerContainer.decoration as BoxDecoration; + + expect(decoration.shape, equals(BoxShape.circle)); + }); + + testWidgets('inner container has gradient', (WidgetTester tester) async { + await tester.pumpWidget(createWidgetUnderTest()); + + final containerFinder = find.descendant( + of: find.byType(AppLogoWidget), + matching: find.byType(Container), + ); + + final containers = tester + .widgetList(containerFinder) + .toList(); + // The second Container is the inner one with gradient + final innerContainer = containers[1]; + final decoration = innerContainer.decoration as BoxDecoration; + + expect(decoration.gradient, isNotNull); + expect(decoration.gradient, isA()); + }); + + testWidgets('icon color is white', (WidgetTester tester) async { + await tester.pumpWidget(createWidgetUnderTest()); + + final icon = tester.widget( + find.byIcon(Icons.directions_bus_rounded), + ); + expect(icon.color, equals(Colors.white)); + }); + }); + + // ============================================================ + // Inner Container Dimension Tests + // ============================================================ + group('Inner Container Dimensions', () { + testWidgets('small size has correct inner dimensions (28x28)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(createWidgetUnderTest(size: AppLogoSize.small)); + + final containerFinder = find.descendant( + of: find.byType(AppLogoWidget), + matching: find.byType(Container), + ); + + final containers = tester + .widgetList(containerFinder) + .toList(); + // Second container is the inner one + final innerContainer = containers[1]; + + expect(innerContainer.constraints?.maxWidth, equals(28.0)); + expect(innerContainer.constraints?.maxHeight, equals(28.0)); + }); + + testWidgets('medium size has correct inner dimensions (56x56)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + createWidgetUnderTest(size: AppLogoSize.medium), + ); + + final containerFinder = find.descendant( + of: find.byType(AppLogoWidget), + matching: find.byType(Container), + ); + + final containers = tester + .widgetList(containerFinder) + .toList(); + final innerContainer = containers[1]; + + expect(innerContainer.constraints?.maxWidth, equals(56.0)); + expect(innerContainer.constraints?.maxHeight, equals(56.0)); + }); + + testWidgets('large size has correct inner dimensions (100x100)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(createWidgetUnderTest(size: AppLogoSize.large)); + + final containerFinder = find.descendant( + of: find.byType(AppLogoWidget), + matching: find.byType(Container), + ); + + final containers = tester + .widgetList(containerFinder) + .toList(); + final innerContainer = containers[1]; + + expect(innerContainer.constraints?.maxWidth, equals(100.0)); + expect(innerContainer.constraints?.maxHeight, equals(100.0)); + }); + }); + + // ============================================================ + // Edge Cases + // Note: This widget has no null/undefined inputs, boundary values, + // invalid data, or error states because: + // - size is a fixed enum with 3 values (no invalid inputs possible) + // - showShadow is a boolean with a default value + // - No async operations or network calls + // - No error states to handle + // Performance and concurrency tests are not applicable for this + // simple presentational widget. + // ============================================================ + }); + + // ============================================================ + // AppLogoSize Enum Tests + // ============================================================ + group('AppLogoSize Enum', () { + test('enum has exactly 3 values', () { + expect(AppLogoSize.values.length, equals(3)); + }); + + test('enum contains small, medium, and large', () { + expect(AppLogoSize.values, contains(AppLogoSize.small)); + expect(AppLogoSize.values, contains(AppLogoSize.medium)); + expect(AppLogoSize.values, contains(AppLogoSize.large)); + }); + }); +} diff --git a/tool/wcag_contrast_checker.dart b/tool/wcag_contrast_checker.dart new file mode 100644 index 0000000..8a7cb68 --- /dev/null +++ b/tool/wcag_contrast_checker.dart @@ -0,0 +1,736 @@ +// ignore_for_file: avoid_print + +// WCAG 2.1 Contrast Checker Tool for PH Fare Calculator +// Run with: dart run tool/wcag_contrast_checker.dart +// +// This tool audits color contrast ratios for accessibility compliance. +// Based on WCAG 2.1 guidelines: https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html + +import 'dart:io'; +import 'dart:math' as math; + +/// Represents an RGB color with 8-bit components. +class Color { + final int r; + final int g; + final int b; + final String name; + final String hex; + + const Color(this.r, this.g, this.b, this.name, this.hex); + + /// Create from hex string like 0xFF141218 or #141218 + factory Color.fromHex(int hex, String name) { + final r = (hex >> 16) & 0xFF; + final g = (hex >> 8) & 0xFF; + final b = hex & 0xFF; + final hexStr = + '#${r.toRadixString(16).padLeft(2, '0')}${g.toRadixString(16).padLeft(2, '0')}${b.toRadixString(16).padLeft(2, '0')}' + .toUpperCase(); + return Color(r, g, b, name, hexStr); + } + + @override + String toString() => '$name ($hex)'; +} + +/// Result of a contrast check between two colors. +class ContrastResult { + final Color foreground; + final Color background; + final double ratio; + final String context; + final double requiredRatio; + final bool passes; + + ContrastResult({ + required this.foreground, + required this.background, + required this.ratio, + required this.context, + required this.requiredRatio, + }) : passes = ratio >= requiredRatio; + + String get status => passes ? '✅ PASS' : '❌ FAIL'; + + String get ratioStr => '${ratio.toStringAsFixed(2)}:1'; +} + +/// WCAG 2.1 Contrast Ratio Calculator +/// +/// Formula: (L1 + 0.05) / (L2 + 0.05) +/// Where L1 is the relative luminance of the lighter color +/// and L2 is the relative luminance of the darker color. +class WCAGContrastChecker { + /// Calculate relative luminance for a color. + /// + /// Formula: L = 0.2126 * R + 0.7152 * G + 0.0722 * B + /// Where R, G, B are linearized sRGB values. + static double relativeLuminance(Color color) { + double linearize(int c) { + final sRGB = c / 255.0; + if (sRGB <= 0.03928) { + return sRGB / 12.92; + } else { + return math.pow((sRGB + 0.055) / 1.055, 2.4).toDouble(); + } + } + + final r = linearize(color.r); + final g = linearize(color.g); + final b = linearize(color.b); + + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + + /// Calculate contrast ratio between two colors. + /// + /// Returns a value between 1:1 and 21:1. + static double contrastRatio(Color fg, Color bg) { + final lum1 = relativeLuminance(fg); + final lum2 = relativeLuminance(bg); + + final lighter = math.max(lum1, lum2); + final darker = math.min(lum1, lum2); + + return (lighter + 0.05) / (darker + 0.05); + } + + /// Check contrast and return result. + static ContrastResult check({ + required Color foreground, + required Color background, + required String context, + required double requiredRatio, + }) { + final ratio = contrastRatio(foreground, background); + return ContrastResult( + foreground: foreground, + background: background, + ratio: ratio, + context: context, + requiredRatio: requiredRatio, + ); + } +} + +// ============================================================================= +// Theme Colors extracted from lib/src/core/theme/app_theme.dart +// and lib/src/core/theme/transit_colors.dart +// ============================================================================= + +// Light Theme Colors (from ColorScheme.fromSeed with seedColor 0xFF0038A8) +// Note: These are the actual generated M3 colors from the seed +class LightThemeColors { + // M3 generates these from seed 0xFF0038A8 (PH Blue) + static final surface = Color.fromHex(0xFFFFFFFF, 'surface'); + static final surfaceContainerLowest = Color.fromHex( + 0xFFF8F9FA, + 'surfaceContainerLowest', + ); + static final onSurface = Color.fromHex( + 0xFF1A1C1E, + 'onSurface', + ); // M3 default dark + static final onSurfaceVariant = Color.fromHex( + 0xFF44474E, + 'onSurfaceVariant', + ); // M3 default + static final primary = Color.fromHex(0xFF0038A8, 'primary'); // Seed color + static final onPrimary = Color.fromHex(0xFFFFFFFF, 'onPrimary'); + static final secondary = Color.fromHex(0xFFFCD116, 'secondary'); // PH Yellow + static final onSecondary = Color.fromHex( + 0xFF000000, + 'onSecondary', + ); // Dark for contrast + static final error = Color.fromHex(0xFFBA1A1A, 'error'); // M3 default error + static final onError = Color.fromHex(0xFFFFFFFF, 'onError'); + static final outline = Color.fromHex( + 0xFF74777F, + 'outline', + ); // M3 default outline + static final surfaceContainer = Color.fromHex( + 0xFFEEEFF2, + 'surfaceContainer', + ); // M3 default +} + +// Dark Theme Colors (explicitly defined in app_theme.dart) +class DarkThemeColors { + static final surface = Color.fromHex(0xFF141218, 'surface'); + static final surfaceContainerLowest = Color.fromHex( + 0xFF0F0D13, + 'surfaceContainerLowest', + ); + static final surfaceContainerLow = Color.fromHex( + 0xFF1D1B20, + 'surfaceContainerLow', + ); + static final surfaceContainer = Color.fromHex(0xFF211F26, 'surfaceContainer'); + static final surfaceContainerHigh = Color.fromHex( + 0xFF2B2930, + 'surfaceContainerHigh', + ); + static final surfaceContainerHighest = Color.fromHex( + 0xFF36343B, + 'surfaceContainerHighest', + ); + static final onSurface = Color.fromHex(0xFFE6E0E9, 'onSurface'); + static final onSurfaceVariant = Color.fromHex(0xFFCAC4D0, 'onSurfaceVariant'); + static final outline = Color.fromHex(0xFF938F99, 'outline'); + static final outlineVariant = Color.fromHex(0xFF49454F, 'outlineVariant'); + static final primary = Color.fromHex(0xFFB8C9FF, 'primary'); // Pastel blue + static final onPrimary = Color.fromHex(0xFF002C71, 'onPrimary'); + static final primaryContainer = Color.fromHex(0xFF1B4496, 'primaryContainer'); + static final onPrimaryContainer = Color.fromHex( + 0xFFD9E2FF, + 'onPrimaryContainer', + ); + static final secondary = Color.fromHex( + 0xFFE5C54C, + 'secondary', + ); // Pastel yellow + static final onSecondary = Color.fromHex(0xFF3B2F00, 'onSecondary'); + static final tertiary = Color.fromHex(0xFFFFB4AB, 'tertiary'); // Pastel red + static final onTertiary = Color.fromHex(0xFF561E18, 'onTertiary'); + static final error = Color.fromHex(0xFFF2B8B5, 'error'); // M3 soft error + static final onError = Color.fromHex( + 0xFF601410, + 'onError', + ); // M3 dark on error +} + +// Light Transit Colors (from transit_colors.dart) - WCAG AA compliant +class LightTransitColors { + static final lrt1 = Color.fromHex( + 0xFF2E7D32, + 'lrt1 (Darker Green)', + ); // Fixed: 4.52:1 + static final lrt2 = Color.fromHex(0xFF7B1FA2, 'lrt2 (Purple)'); + static final mrt3 = Color.fromHex( + 0xFF1565C0, + 'mrt3 (Darker Blue)', + ); // Fixed: 4.62:1 + static final mrt7 = Color.fromHex( + 0xFFE65100, + 'mrt7 (Darker Orange)', + ); // Fixed: 3.26:1 + static final pnr = Color.fromHex(0xFF795548, 'pnr (Brown)'); + static final jeep = Color.fromHex(0xFF00695C, 'jeep (Teal)'); + static final bus = Color.fromHex(0xFFC62828, 'bus (Red)'); + static final discountStudent = Color.fromHex( + 0xFF1976D2, + 'discountStudent (Blue)', + ); + static final discountSenior = Color.fromHex( + 0xFF7B1FA2, + 'discountSenior (Purple)', + ); + static final discountPwd = Color.fromHex(0xFF388E3C, 'discountPwd (Green)'); + static final discountBadge = Color.fromHex( + 0xFFA5D6A7, + 'discountBadge (Pastel Green)', + ); // Fixed for text contrast + static final discountBadgeText = Color.fromHex( + 0xFF1B5E20, + 'discountBadgeText (Dark Green)', + ); // 5.24:1 on pastel +} + +// Dark Transit Colors (from transit_colors.dart) +class DarkTransitColors { + static final lrt1 = Color.fromHex(0xFFA8D5AA, 'lrt1 (Pastel Green)'); + static final lrt2 = Color.fromHex(0xFFD4B8E0, 'lrt2 (Pastel Purple)'); + static final mrt3 = Color.fromHex(0xFFABC8E8, 'mrt3 (Pastel Blue)'); + static final mrt7 = Color.fromHex(0xFFE8CFA8, 'mrt7 (Pastel Orange)'); + static final pnr = Color.fromHex(0xFFC4B5AD, 'pnr (Pastel Brown)'); + static final jeep = Color.fromHex(0xFF9DCDC6, 'jeep (Pastel Teal)'); + static final bus = Color.fromHex(0xFFE8AEAB, 'bus (Pastel Red)'); + static final discountStudent = Color.fromHex( + 0xFFABC8E8, + 'discountStudent (Pastel Blue)', + ); + static final discountSenior = Color.fromHex( + 0xFFD4B8E0, + 'discountSenior (Pastel Purple)', + ); + static final discountPwd = Color.fromHex( + 0xFFA8D5AA, + 'discountPwd (Pastel Green)', + ); + static final discountBadge = Color.fromHex( + 0xFFA8D5AA, + 'discountBadge (Pastel Green)', + ); + static final discountBadgeText = Color.fromHex( + 0xFF1B3D1D, + 'discountBadgeText (Dark Green)', + ); +} + +/// Runs all contrast checks and returns results. +List runAllChecks() { + final results = []; + + // WCAG AA Requirements: + // - 4.5:1 for normal text (<18pt or <14pt bold) + // - 3:1 for large text (≥18pt or ≥14pt bold) and UI components + + const normalTextRatio = 4.5; + const largeTextUIRatio = 3.0; + + // ========================================================================== + // LIGHT THEME CHECKS + // ========================================================================== + + // Primary text contrasts + results.add( + WCAGContrastChecker.check( + foreground: LightThemeColors.onSurface, + background: LightThemeColors.surface, + context: 'Light: onSurface on surface (primary text)', + requiredRatio: normalTextRatio, + ), + ); + + results.add( + WCAGContrastChecker.check( + foreground: LightThemeColors.onSurface, + background: LightThemeColors.surfaceContainerLowest, + context: 'Light: onSurface on background (body text)', + requiredRatio: normalTextRatio, + ), + ); + + results.add( + WCAGContrastChecker.check( + foreground: LightThemeColors.primary, + background: LightThemeColors.surface, + context: 'Light: primary on surface (buttons, links)', + requiredRatio: largeTextUIRatio, + ), + ); + + results.add( + WCAGContrastChecker.check( + foreground: LightThemeColors.onPrimary, + background: LightThemeColors.primary, + context: 'Light: onPrimary on primary (button text)', + requiredRatio: normalTextRatio, + ), + ); + + results.add( + WCAGContrastChecker.check( + foreground: LightThemeColors.error, + background: LightThemeColors.surface, + context: 'Light: error on surface (error messages)', + requiredRatio: normalTextRatio, + ), + ); + + results.add( + WCAGContrastChecker.check( + foreground: LightThemeColors.onError, + background: LightThemeColors.error, + context: 'Light: onError on error (error button text)', + requiredRatio: normalTextRatio, + ), + ); + + results.add( + WCAGContrastChecker.check( + foreground: LightThemeColors.outline, + background: LightThemeColors.surface, + context: 'Light: outline on surface (borders)', + requiredRatio: largeTextUIRatio, + ), + ); + + results.add( + WCAGContrastChecker.check( + foreground: LightThemeColors.onSurfaceVariant, + background: LightThemeColors.surfaceContainer, + context: 'Light: onSurfaceVariant on surfaceContainer', + requiredRatio: normalTextRatio, + ), + ); + + // Light theme transit colors on surface + for (final transit in [ + LightTransitColors.lrt1, + LightTransitColors.lrt2, + LightTransitColors.mrt3, + LightTransitColors.mrt7, + LightTransitColors.pnr, + LightTransitColors.jeep, + LightTransitColors.bus, + ]) { + results.add( + WCAGContrastChecker.check( + foreground: transit, + background: LightThemeColors.surface, + context: 'Light: ${transit.name} on surface', + requiredRatio: largeTextUIRatio, + ), + ); + + results.add( + WCAGContrastChecker.check( + foreground: transit, + background: LightThemeColors.surfaceContainer, + context: 'Light: ${transit.name} on surfaceContainer', + requiredRatio: largeTextUIRatio, + ), + ); + } + + // Discount badge text contrast + results.add( + WCAGContrastChecker.check( + foreground: LightTransitColors.discountBadgeText, + background: LightTransitColors.discountBadge, + context: 'Light: discountBadgeText on discountBadge', + requiredRatio: normalTextRatio, + ), + ); + + // ========================================================================== + // DARK THEME CHECKS + // ========================================================================== + + // Primary text contrasts + results.add( + WCAGContrastChecker.check( + foreground: DarkThemeColors.onSurface, + background: DarkThemeColors.surface, + context: 'Dark: onSurface on surface (primary text)', + requiredRatio: normalTextRatio, + ), + ); + + results.add( + WCAGContrastChecker.check( + foreground: DarkThemeColors.onSurface, + background: DarkThemeColors.surfaceContainerLowest, + context: 'Dark: onSurface on background (body text)', + requiredRatio: normalTextRatio, + ), + ); + + results.add( + WCAGContrastChecker.check( + foreground: DarkThemeColors.primary, + background: DarkThemeColors.surface, + context: 'Dark: primary on surface (buttons, links)', + requiredRatio: largeTextUIRatio, + ), + ); + + results.add( + WCAGContrastChecker.check( + foreground: DarkThemeColors.onPrimary, + background: DarkThemeColors.primary, + context: 'Dark: onPrimary on primary (button text)', + requiredRatio: normalTextRatio, + ), + ); + + results.add( + WCAGContrastChecker.check( + foreground: DarkThemeColors.error, + background: DarkThemeColors.surface, + context: 'Dark: error on surface (error messages)', + requiredRatio: normalTextRatio, + ), + ); + + results.add( + WCAGContrastChecker.check( + foreground: DarkThemeColors.onError, + background: DarkThemeColors.error, + context: 'Dark: onError on error (error button text)', + requiredRatio: normalTextRatio, + ), + ); + + results.add( + WCAGContrastChecker.check( + foreground: DarkThemeColors.outline, + background: DarkThemeColors.surface, + context: 'Dark: outline on surface (borders)', + requiredRatio: largeTextUIRatio, + ), + ); + + results.add( + WCAGContrastChecker.check( + foreground: DarkThemeColors.onSurfaceVariant, + background: DarkThemeColors.surfaceContainerHigh, + context: 'Dark: onSurfaceVariant on surfaceContainerHigh', + requiredRatio: normalTextRatio, + ), + ); + + // Dark theme transit colors on surface + for (final transit in [ + DarkTransitColors.lrt1, + DarkTransitColors.lrt2, + DarkTransitColors.mrt3, + DarkTransitColors.mrt7, + DarkTransitColors.pnr, + DarkTransitColors.jeep, + DarkTransitColors.bus, + ]) { + results.add( + WCAGContrastChecker.check( + foreground: transit, + background: DarkThemeColors.surface, + context: 'Dark: ${transit.name} on surface', + requiredRatio: largeTextUIRatio, + ), + ); + + results.add( + WCAGContrastChecker.check( + foreground: transit, + background: DarkThemeColors.surfaceContainer, + context: 'Dark: ${transit.name} on surfaceContainer', + requiredRatio: largeTextUIRatio, + ), + ); + } + + // Discount badge text contrast + results.add( + WCAGContrastChecker.check( + foreground: DarkTransitColors.discountBadgeText, + background: DarkTransitColors.discountBadge, + context: 'Dark: discountBadgeText on discountBadge', + requiredRatio: normalTextRatio, + ), + ); + + // Additional dark theme checks + results.add( + WCAGContrastChecker.check( + foreground: DarkThemeColors.onSurfaceVariant, + background: DarkThemeColors.surfaceContainer, + context: 'Dark: onSurfaceVariant on surfaceContainer', + requiredRatio: normalTextRatio, + ), + ); + + results.add( + WCAGContrastChecker.check( + foreground: DarkThemeColors.secondary, + background: DarkThemeColors.surface, + context: 'Dark: secondary on surface', + requiredRatio: largeTextUIRatio, + ), + ); + + results.add( + WCAGContrastChecker.check( + foreground: DarkThemeColors.tertiary, + background: DarkThemeColors.surface, + context: 'Dark: tertiary on surface', + requiredRatio: largeTextUIRatio, + ), + ); + + return results; +} + +/// Generate markdown report. +String generateMarkdownReport(List results) { + final buffer = StringBuffer(); + + buffer.writeln('# WCAG Accessibility Contrast Audit Report'); + buffer.writeln(); + buffer.writeln('**Generated:** ${DateTime.now().toIso8601String()}'); + buffer.writeln(); + buffer.writeln('**Tool:** `dart run tool/wcag_contrast_checker.dart`'); + buffer.writeln(); + buffer.writeln('---'); + buffer.writeln(); + buffer.writeln('## WCAG 2.1 Contrast Requirements'); + buffer.writeln(); + buffer.writeln('| Level | Normal Text | Large Text / UI |'); + buffer.writeln('|-------|-------------|-----------------|'); + buffer.writeln('| **AA (minimum)** | 4.5:1 | 3:1 |'); + buffer.writeln('| **AAA (enhanced)** | 7:1 | 4.5:1 |'); + buffer.writeln(); + buffer.writeln( + '> **Large text** = 18pt+ (24px) or 14pt+ bold (18.67px bold)', + ); + buffer.writeln(); + buffer.writeln('---'); + buffer.writeln(); + + // Summary + final passed = results.where((r) => r.passes).length; + final failed = results.where((r) => !r.passes).length; + final total = results.length; + + buffer.writeln('## Summary'); + buffer.writeln(); + buffer.writeln('| Metric | Count |'); + buffer.writeln('|--------|-------|'); + buffer.writeln('| Total checks | $total |'); + buffer.writeln('| ✅ Passed | $passed |'); + buffer.writeln('| ❌ Failed | $failed |'); + buffer.writeln( + '| Pass rate | ${(passed / total * 100).toStringAsFixed(1)}% |', + ); + buffer.writeln(); + buffer.writeln('---'); + buffer.writeln(); + + // Light Theme Results + buffer.writeln('## Light Theme Results'); + buffer.writeln(); + buffer.writeln( + '| Context | Foreground | Background | Ratio | Required | Status |', + ); + buffer.writeln( + '|---------|------------|------------|-------|----------|--------|', + ); + + for (final r in results.where((r) => r.context.startsWith('Light:'))) { + buffer.writeln( + '| ${r.context.replaceFirst('Light: ', '')} | ${r.foreground.hex} | ${r.background.hex} | ${r.ratioStr} | ${r.requiredRatio}:1 | ${r.status} |', + ); + } + + buffer.writeln(); + buffer.writeln('---'); + buffer.writeln(); + + // Dark Theme Results + buffer.writeln('## Dark Theme Results'); + buffer.writeln(); + buffer.writeln( + '| Context | Foreground | Background | Ratio | Required | Status |', + ); + buffer.writeln( + '|---------|------------|------------|-------|----------|--------|', + ); + + for (final r in results.where((r) => r.context.startsWith('Dark:'))) { + buffer.writeln( + '| ${r.context.replaceFirst('Dark: ', '')} | ${r.foreground.hex} | ${r.background.hex} | ${r.ratioStr} | ${r.requiredRatio}:1 | ${r.status} |', + ); + } + + buffer.writeln(); + + // Failed checks + final failedResults = results.where((r) => !r.passes).toList(); + if (failedResults.isNotEmpty) { + buffer.writeln('---'); + buffer.writeln(); + buffer.writeln('## ❌ Failed Checks - Recommendations'); + buffer.writeln(); + for (final r in failedResults) { + final deficit = r.requiredRatio - r.ratio; + buffer.writeln('### ${r.context}'); + buffer.writeln(); + buffer.writeln('- **Foreground:** ${r.foreground}'); + buffer.writeln('- **Background:** ${r.background}'); + buffer.writeln('- **Current ratio:** ${r.ratioStr}'); + buffer.writeln('- **Required ratio:** ${r.requiredRatio}:1'); + buffer.writeln('- **Deficit:** ${deficit.toStringAsFixed(2)}'); + buffer.writeln(); + buffer.writeln( + '**Recommendation:** Adjust the foreground color to be ${r.foreground.r > 128 ? "darker" : "lighter"} to achieve the required contrast ratio.', + ); + buffer.writeln(); + } + } else { + buffer.writeln('---'); + buffer.writeln(); + buffer.writeln('## ✅ All Checks Passed'); + buffer.writeln(); + buffer.writeln( + 'All color combinations meet WCAG 2.1 AA minimum contrast requirements.', + ); + } + + buffer.writeln(); + buffer.writeln('---'); + buffer.writeln(); + buffer.writeln('## Technical Notes'); + buffer.writeln(); + buffer.writeln('### Relative Luminance Formula (sRGB)'); + buffer.writeln(); + buffer.writeln('```'); + buffer.writeln('L = 0.2126 * R + 0.7152 * G + 0.0722 * B'); + buffer.writeln('```'); + buffer.writeln(); + buffer.writeln('Where R, G, B are linearized values:'); + buffer.writeln('```'); + buffer.writeln('if sRGB <= 0.03928:'); + buffer.writeln(' linear = sRGB / 12.92'); + buffer.writeln('else:'); + buffer.writeln(' linear = ((sRGB + 0.055) / 1.055) ^ 2.4'); + buffer.writeln('```'); + buffer.writeln(); + buffer.writeln('### Contrast Ratio Formula'); + buffer.writeln(); + buffer.writeln('```'); + buffer.writeln('ratio = (L1 + 0.05) / (L2 + 0.05)'); + buffer.writeln('```'); + buffer.writeln(); + buffer.writeln( + 'Where L1 is the luminance of the lighter color and L2 is the luminance of the darker color.', + ); + + return buffer.toString(); +} + +void main() { + print('🎨 WCAG 2.1 Contrast Checker for PH Fare Calculator'); + print('=' * 60); + print(''); + + final results = runAllChecks(); + + // Print console summary + final passed = results.where((r) => r.passes).length; + final failed = results.where((r) => !r.passes).length; + + print('Summary:'); + print(' Total checks: ${results.length}'); + print(' ✅ Passed: $passed'); + print(' ❌ Failed: $failed'); + print(''); + + // Print failed checks to console + if (failed > 0) { + print('Failed Checks:'); + for (final r in results.where((r) => !r.passes)) { + print(' ❌ ${r.context}'); + print(' ${r.foreground} on ${r.background}'); + print(' Ratio: ${r.ratioStr} (required: ${r.requiredRatio}:1)'); + print(''); + } + } + + // Generate and write markdown report + final report = generateMarkdownReport(results); + final reportFile = File('docs/accessibility_audit.md'); + reportFile.writeAsStringSync(report); + + print('📄 Report written to: docs/accessibility_audit.md'); + print(''); + + // Exit with appropriate code + if (failed > 0) { + print('⚠️ Some contrast checks failed. Review the report for details.'); + exit(1); + } else { + print('✅ All contrast checks passed!'); + exit(0); + } +}