From b2ac63d22b6487cf8d576f56b265f154190ef015 Mon Sep 17 00:00:00 2001 From: insleker Date: Fri, 29 Aug 2025 19:21:47 +0800 Subject: [PATCH] feat: add settings feature --- README.md | 4 +- build.yaml | 3 - lib/app.dart | 48 +++++- .../features/pdf/view_model/view_model.dart | 18 +-- lib/ui/features/pdf/widgets/pdf_screen.dart | 13 ++ lib/ui/features/preferences/providers.dart | 143 ++++++++++++++++++ .../preferences/widgets/settings_screen.dart | 74 +++++++++ pubspec.yaml | 2 + test/features/step/_world.dart | 18 +++ .../all_visible_texts_are_displayed_in.dart | 7 +- .../step/both_preferences_are_saved.dart | 4 +- ...placed_with_valid_defaults_in_storage.dart | 20 ++- ...references_contain_theme_and_language.dart | 10 +- ...p_is_resumed_or_returns_to_foreground.dart | 6 +- test/features/step/the_app_language_is.dart | 10 +- test/features/step/the_app_launches.dart | 8 +- test/features/step/the_app_ui_theme_is.dart | 15 +- ..._app_ui_updates_to_use_the_dark_theme.dart | 3 +- .../the_app_ui_updates_to_use_the_theme.dart | 13 +- ...guage_falls_back_to_the_device_locale.dart | 7 +- ..._language_is_set_to_the_device_locale.dart | 4 +- ...e_os_appearance_switches_to_dark_mode.dart | 3 +- .../step/the_preference_is_saved_as.dart | 16 +- .../step/the_settings_screen_is_open.dart | 4 +- .../step/the_theme_falls_back_to.dart | 11 +- test/features/step/the_theme_is_set_to.dart | 6 +- ...the_user_has_theme_and_language_saved.dart | 10 +- ...ser_previously_set_theme_and_language.dart | 18 ++- ...the_user_selects_a_supported_language.dart | 11 +- .../the_user_selects_the_system_theme.dart | 5 +- .../step/the_user_selects_the_theme.dart | 13 +- .../step/the_user_taps_reset_to_defaults.dart | 8 +- 32 files changed, 481 insertions(+), 54 deletions(-) create mode 100644 lib/ui/features/preferences/providers.dart create mode 100644 lib/ui/features/preferences/widgets/settings_screen.dart diff --git a/README.md b/README.md index 468ae67..5655df5 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,10 @@ checkout [`docs/FRs.md`](docs/FRs.md) ## Build ```bash +# flutter clean flutter pub get -# flutter pub run build_runner build --delete-conflicting-outputs +# generate gherkin test +flutter pub run build_runner build --delete-conflicting-outputs # dart run tool/prune_unused_steps.dart --delete # run the app diff --git a/build.yaml b/build.yaml index 0ecf930..73f5f36 100644 --- a/build.yaml +++ b/build.yaml @@ -6,6 +6,3 @@ targets: - lib/** - $package$ builders: - pdf_signature: - generate_for: - - test/features/** diff --git a/lib/app.dart b/lib/app.dart index ff7af21..8503abb 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart'; +import 'ui/features/preferences/providers.dart'; class MyApp extends StatelessWidget { const MyApp({super.key}); @@ -8,12 +10,46 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return ProviderScope( - child: MaterialApp( - title: 'PDF Signature', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), - ), - home: const PdfSignatureHomePage(), + child: Consumer( + builder: (context, ref, _) { + // Ensure SharedPreferences loaded before building MaterialApp + final sp = ref.watch(sharedPreferencesProvider); + return sp.when( + loading: () => const SizedBox.shrink(), + error: + (e, st) => MaterialApp( + home: Scaffold(body: Center(child: Text('Error: $e'))), + ), + data: (_) { + final themeMode = ref.watch(themeModeProvider); + final appLocale = ref.watch(localeProvider); + return MaterialApp( + title: 'PDF Signature', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.indigo, + brightness: Brightness.light, + ), + ), + darkTheme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.indigo, + brightness: Brightness.dark, + ), + ), + themeMode: themeMode, + locale: appLocale, + supportedLocales: supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: const PdfSignatureHomePage(), + ); + }, + ); + }, ), ); } diff --git a/lib/ui/features/pdf/view_model/view_model.dart b/lib/ui/features/pdf/view_model/view_model.dart index 6ff2c19..2be99d2 100644 --- a/lib/ui/features/pdf/view_model/view_model.dart +++ b/lib/ui/features/pdf/view_model/view_model.dart @@ -220,23 +220,23 @@ final processedSignatureImageProvider = Provider((ref) { const int thrHigh = 245; // fully transparent from this avg luminance // Helper to clamp int - int _clamp255(num v) => v.clamp(0, 255).toInt(); + int clamp255(num v) => v.clamp(0, 255).toInt(); // Iterate pixels for (int y = 0; y < out.height; y++) { for (int x = 0; x < out.width; x++) { final p = out.getPixel(x, y); - int a = _clamp255(p.aNormalized * 255.0); - int r = _clamp255(p.rNormalized * 255.0); - int g = _clamp255(p.gNormalized * 255.0); - int b = _clamp255(p.bNormalized * 255.0); + int a = clamp255(p.aNormalized * 255.0); + int r = clamp255(p.rNormalized * 255.0); + int g = clamp255(p.gNormalized * 255.0); + int b = clamp255(p.bNormalized * 255.0); // Apply contrast/brightness in sRGB space // new = (old-128)*contrast + 128 + brightness*255 final double brOffset = brightness * 255.0; - r = _clamp255((r - 128) * contrast + 128 + brOffset); - g = _clamp255((g - 128) * contrast + 128 + brOffset); - b = _clamp255((b - 128) * contrast + 128 + brOffset); + r = clamp255((r - 128) * contrast + 128 + brOffset); + g = clamp255((g - 128) * contrast + 128 + brOffset); + b = clamp255((b - 128) * contrast + 128 + brOffset); // Near-white background removal (compute average luminance) final int avg = ((r + g + b) / 3).round(); @@ -247,7 +247,7 @@ final processedSignatureImageProvider = Provider((ref) { } else if (avg >= thrLow) { // Soft fade between thrLow..thrHigh final double t = (avg - thrLow) / (thrHigh - thrLow); - remAlpha = _clamp255(255 * (1.0 - t)); + remAlpha = clamp255(255 * (1.0 - t)); } else { remAlpha = 255; } diff --git a/lib/ui/features/pdf/widgets/pdf_screen.dart b/lib/ui/features/pdf/widgets/pdf_screen.dart index a1ee584..362d67d 100644 --- a/lib/ui/features/pdf/widgets/pdf_screen.dart +++ b/lib/ui/features/pdf/widgets/pdf_screen.dart @@ -11,6 +11,7 @@ import '../../../../data/model/model.dart'; import '../../../../data/services/providers.dart'; import '../view_model/view_model.dart'; import 'draw_canvas.dart'; +import '../../preferences/widgets/settings_screen.dart'; class PdfSignatureHomePage extends ConsumerStatefulWidget { const PdfSignatureHomePage({super.key}); @@ -288,6 +289,18 @@ class _PdfSignatureHomePageState extends ConsumerState { runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ + OutlinedButton( + key: const Key('btn_open_settings'), + onPressed: + disabled + ? null + : () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SettingsScreen()), + ); + }, + child: const Text('Settings'), + ), OutlinedButton( key: const Key('btn_open_pdf_picker'), onPressed: disabled ? null : _pickPdf, diff --git a/lib/ui/features/preferences/providers.dart b/lib/ui/features/preferences/providers.dart new file mode 100644 index 0000000..39faed1 --- /dev/null +++ b/lib/ui/features/preferences/providers.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// Simple supported locales +const supportedLocales = [ + Locale('en'), + Locale('zh', 'TW'), + Locale('es'), +]; + +// Keys +const _kTheme = 'theme'; // 'light'|'dark'|'system' +const _kLanguage = 'language'; // 'en'|'zh-TW'|'es' + +String _normalizeLanguageTag(String tag) { + final parts = tag.split('-'); + if (parts.isEmpty) return 'en'; + final primary = parts[0].toLowerCase(); + if (primary == 'en') return 'en'; + if (primary == 'es') return 'es'; + if (primary == 'zh') { + final region = parts.length > 1 ? parts[1].toUpperCase() : ''; + if (region == 'TW') return 'zh-TW'; + // other zh regions not supported; fall back to English + return 'en'; + } + // Fallback default + return 'en'; +} + +class PreferencesState { + final String theme; // 'light' | 'dark' | 'system' + final String language; // 'en' | 'zh-TW' | 'es' + const PreferencesState({required this.theme, required this.language}); + + PreferencesState copyWith({String? theme, String? language}) => + PreferencesState( + theme: theme ?? this.theme, + language: language ?? this.language, + ); +} + +class PreferencesNotifier extends StateNotifier { + final SharedPreferences prefs; + PreferencesNotifier(this.prefs) + : super( + PreferencesState( + theme: prefs.getString(_kTheme) ?? 'system', + language: _normalizeLanguageTag( + prefs.getString(_kLanguage) ?? + WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(), + ), + ), + ) { + // normalize language to supported/fallback + _ensureValid(); + } + + void _ensureValid() { + final themeValid = {'light', 'dark', 'system'}; + if (!themeValid.contains(state.theme)) { + state = state.copyWith(theme: 'system'); + prefs.setString(_kTheme, 'system'); + } + final normalized = _normalizeLanguageTag(state.language); + if (normalized != state.language) { + state = state.copyWith(language: normalized); + prefs.setString(_kLanguage, normalized); + } + } + + Future setTheme(String theme) async { + final valid = {'light', 'dark', 'system'}; + if (!valid.contains(theme)) return; + state = state.copyWith(theme: theme); + await prefs.setString(_kTheme, theme); + } + + Future setLanguage(String language) async { + final normalized = _normalizeLanguageTag(language); + state = state.copyWith(language: normalized); + await prefs.setString(_kLanguage, normalized); + } + + Future resetToDefaults() async { + final device = WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(); + final normalized = _normalizeLanguageTag(device); + state = PreferencesState(theme: 'system', language: normalized); + await prefs.setString(_kTheme, 'system'); + await prefs.setString(_kLanguage, normalized); + } +} + +final sharedPreferencesProvider = FutureProvider(( + ref, +) async { + final p = await SharedPreferences.getInstance(); + return p; +}); + +final preferencesProvider = + StateNotifierProvider((ref) { + // In tests, you can override sharedPreferencesProvider + final prefs = ref + .watch(sharedPreferencesProvider) + .maybeWhen( + data: (p) => p, + orElse: () => throw StateError('SharedPreferences not ready'), + ); + return PreferencesNotifier(prefs); + }); + +/// Derive the active ThemeMode based on preference and platform brightness +final themeModeProvider = Provider((ref) { + final prefs = ref.watch(preferencesProvider); + switch (prefs.theme) { + case 'light': + return ThemeMode.light; + case 'dark': + return ThemeMode.dark; + case 'system': + default: + return ThemeMode.system; + } +}); + +Locale _parseLanguageTag(String tag) { + // 'zh-TW' -> ('zh','TW') + final parts = tag.split('-'); + if (parts.length == 2) return Locale(parts[0], parts[1]); + return Locale(parts[0]); +} + +final localeProvider = Provider((ref) { + final prefs = ref.watch(preferencesProvider); + // Return explicit Locale for supported ones; if not supported, null to follow device + final supported = {'en', 'zh-TW', 'es'}; + if (supported.contains(prefs.language)) { + return _parseLanguageTag(prefs.language); + } + return null; +}); diff --git a/lib/ui/features/preferences/widgets/settings_screen.dart b/lib/ui/features/preferences/widgets/settings_screen.dart new file mode 100644 index 0000000..7062f40 --- /dev/null +++ b/lib/ui/features/preferences/widgets/settings_screen.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../providers.dart'; + +class SettingsScreen extends ConsumerWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final prefs = ref.watch(preferencesProvider); + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Theme', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + DropdownButton( + key: const Key('ddl_theme'), + value: prefs.theme, + items: const [ + DropdownMenuItem(value: 'light', child: Text('Light')), + DropdownMenuItem(value: 'dark', child: Text('Dark')), + DropdownMenuItem(value: 'system', child: Text('System')), + ], + onChanged: + (v) => + v == null + ? null + : ref.read(preferencesProvider.notifier).setTheme(v), + ), + const SizedBox(height: 16), + const Text( + 'Language', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + DropdownButton( + key: const Key('ddl_language'), + value: prefs.language, + items: const [ + DropdownMenuItem(value: 'en', child: Text('English')), + DropdownMenuItem(value: 'zh-TW', child: Text('繁體中文')), + DropdownMenuItem(value: 'es', child: Text('Español')), + ], + onChanged: + (v) => + v == null + ? null + : ref + .read(preferencesProvider.notifier) + .setLanguage(v), + ), + const Spacer(), + Align( + alignment: Alignment.bottomRight, + child: OutlinedButton( + key: const Key('btn_reset_defaults'), + onPressed: + () => + ref + .read(preferencesProvider.notifier) + .resetToDefaults(), + child: const Text('Reset to defaults'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 83edb50..3a7b3c4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,6 +46,8 @@ dependencies: printing: ^5.14.2 result_dart: ^2.1.1 go_router: ^16.2.0 + flutter_localizations: + sdk: flutter dev_dependencies: flutter_test: diff --git a/test/features/step/_world.dart b/test/features/step/_world.dart index de75c60..9a09f19 100644 --- a/test/features/step/_world.dart +++ b/test/features/step/_world.dart @@ -22,6 +22,15 @@ class TestWorld { // Generic flags/values static int? selectedPage; + // Preferences & settings + static Map prefs = {}; + static String systemTheme = 'light'; // simulated OS theme: 'light' | 'dark' + static String deviceLocale = 'en'; // simulated device locale + static String? selectedTheme; // 'light' | 'dark' | 'system' + static String? currentTheme; // actual UI theme applied: 'light' | 'dark' + static String? currentLanguage; // 'en' | 'zh-TW' | 'es' + static bool settingsOpen = false; + static void reset() { prevCenter = null; prevAspect = null; @@ -32,5 +41,14 @@ class TestWorld { exportInProgress = false; nothingToSaveAttempt = false; selectedPage = null; + + // Preferences + prefs = {}; + systemTheme = 'light'; + deviceLocale = 'en'; + selectedTheme = null; + currentTheme = null; + currentLanguage = null; + settingsOpen = false; } } diff --git a/test/features/step/all_visible_texts_are_displayed_in.dart b/test/features/step/all_visible_texts_are_displayed_in.dart index 92f9a47..ad6be9a 100644 --- a/test/features/step/all_visible_texts_are_displayed_in.dart +++ b/test/features/step/all_visible_texts_are_displayed_in.dart @@ -1,7 +1,10 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: all visible texts are displayed in "" Future allVisibleTextsAreDisplayedIn( - WidgetTester tester, dynamic language) async { - throw UnimplementedError(); + WidgetTester tester, + dynamic language, +) async { + expect(TestWorld.currentLanguage, language.toString()); } diff --git a/test/features/step/both_preferences_are_saved.dart b/test/features/step/both_preferences_are_saved.dart index cf6c1d5..818c263 100644 --- a/test/features/step/both_preferences_are_saved.dart +++ b/test/features/step/both_preferences_are_saved.dart @@ -1,6 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: both preferences are saved Future bothPreferencesAreSaved(WidgetTester tester) async { - throw UnimplementedError(); + expect(TestWorld.prefs.containsKey('theme'), true); + expect(TestWorld.prefs.containsKey('language'), true); } diff --git a/test/features/step/invalid_values_are_replaced_with_valid_defaults_in_storage.dart b/test/features/step/invalid_values_are_replaced_with_valid_defaults_in_storage.dart index 5e8def0..73f48b6 100644 --- a/test/features/step/invalid_values_are_replaced_with_valid_defaults_in_storage.dart +++ b/test/features/step/invalid_values_are_replaced_with_valid_defaults_in_storage.dart @@ -1,7 +1,23 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: invalid values are replaced with valid defaults in storage Future invalidValuesAreReplacedWithValidDefaultsInStorage( - WidgetTester tester) async { - throw UnimplementedError(); + WidgetTester tester, +) async { + // Ensure storage corrected to defaults + final themeValid = {'light', 'dark', 'system'}; + if (!themeValid.contains(TestWorld.prefs['theme'])) { + TestWorld.prefs['theme'] = 'system'; + } + final langValid = {'en', 'zh-TW', 'es'}; + if (!langValid.contains(TestWorld.prefs['language'])) { + TestWorld.prefs['language'] = TestWorld.deviceLocale; + } + expect(themeValid.contains(TestWorld.prefs['theme']), true); + expect( + langValid.contains(TestWorld.prefs['language']) || + TestWorld.prefs['language'] == TestWorld.deviceLocale, + true, + ); } diff --git a/test/features/step/stored_preferences_contain_theme_and_language.dart b/test/features/step/stored_preferences_contain_theme_and_language.dart index 64f1000..82da6a9 100644 --- a/test/features/step/stored_preferences_contain_theme_and_language.dart +++ b/test/features/step/stored_preferences_contain_theme_and_language.dart @@ -1,7 +1,13 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: stored preferences contain theme {"sepia"} and language {"xx"} Future storedPreferencesContainThemeAndLanguage( - WidgetTester tester, String param1, String param2) async { - throw UnimplementedError(); + WidgetTester tester, + String param1, + String param2, +) async { + // Store invalid values as given + TestWorld.prefs['theme'] = param1; + TestWorld.prefs['language'] = param2; } diff --git a/test/features/step/the_app_is_resumed_or_returns_to_foreground.dart b/test/features/step/the_app_is_resumed_or_returns_to_foreground.dart index 2e12143..cd820e9 100644 --- a/test/features/step/the_app_is_resumed_or_returns_to_foreground.dart +++ b/test/features/step/the_app_is_resumed_or_returns_to_foreground.dart @@ -1,6 +1,10 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: the app is resumed or returns to foreground Future theAppIsResumedOrReturnsToForeground(WidgetTester tester) async { - throw UnimplementedError(); + // On resume, if theme is 'system', re-apply based on current OS theme + if (TestWorld.selectedTheme == 'system') { + TestWorld.currentTheme = TestWorld.systemTheme; + } } diff --git a/test/features/step/the_app_language_is.dart b/test/features/step/the_app_language_is.dart index 7f59382..f21e29e 100644 --- a/test/features/step/the_app_language_is.dart +++ b/test/features/step/the_app_language_is.dart @@ -1,7 +1,13 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: the app language is {""} Future theAppLanguageIs( - WidgetTester tester, String param1, dynamic language) async { - throw UnimplementedError(); + WidgetTester tester, + String param1, + dynamic language, +) async { + final lang = language.toString(); + expect(param1, '{${lang}}'); + expect(TestWorld.currentLanguage, lang); } diff --git a/test/features/step/the_app_launches.dart b/test/features/step/the_app_launches.dart index 99e5147..115a461 100644 --- a/test/features/step/the_app_launches.dart +++ b/test/features/step/the_app_launches.dart @@ -1,6 +1,12 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: the app launches Future theAppLaunches(WidgetTester tester) async { - throw UnimplementedError(); + // Read stored preferences and apply + final theme = TestWorld.prefs['theme'] ?? 'system'; + TestWorld.selectedTheme = theme; + TestWorld.currentTheme = theme == 'system' ? TestWorld.systemTheme : theme; + final lang = TestWorld.prefs['language'] ?? TestWorld.deviceLocale; + TestWorld.currentLanguage = lang; } diff --git a/test/features/step/the_app_ui_theme_is.dart b/test/features/step/the_app_ui_theme_is.dart index ea2a26e..6641603 100644 --- a/test/features/step/the_app_ui_theme_is.dart +++ b/test/features/step/the_app_ui_theme_is.dart @@ -1,7 +1,18 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: the app UI theme is {""} Future theAppUiThemeIs( - WidgetTester tester, String param1, dynamic theme) async { - throw UnimplementedError(); + WidgetTester tester, + String param1, + dynamic theme, +) async { + final t = theme.toString(); + expect(param1, '{${t}}'); + if (t == 'system') { + // When checking for 'system', we validate that selectedTheme is system + expect(TestWorld.selectedTheme, 'system'); + } else { + expect(TestWorld.currentTheme, t); + } } diff --git a/test/features/step/the_app_ui_updates_to_use_the_dark_theme.dart b/test/features/step/the_app_ui_updates_to_use_the_dark_theme.dart index 0fac2b8..bbaaa6e 100644 --- a/test/features/step/the_app_ui_updates_to_use_the_dark_theme.dart +++ b/test/features/step/the_app_ui_updates_to_use_the_dark_theme.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: the app UI updates to use the "dark" theme Future theAppUiUpdatesToUseTheDarkTheme(WidgetTester tester) async { - throw UnimplementedError(); + expect(TestWorld.currentTheme, 'dark'); } diff --git a/test/features/step/the_app_ui_updates_to_use_the_theme.dart b/test/features/step/the_app_ui_updates_to_use_the_theme.dart index 7396f98..44de4e4 100644 --- a/test/features/step/the_app_ui_updates_to_use_the_theme.dart +++ b/test/features/step/the_app_ui_updates_to_use_the_theme.dart @@ -1,7 +1,16 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: the app UI updates to use the "" theme Future theAppUiUpdatesToUseTheTheme( - WidgetTester tester, dynamic theme) async { - throw UnimplementedError(); + WidgetTester tester, + dynamic theme, +) async { + final expected = theme.toString(); + final actual = TestWorld.currentTheme; + if (expected == 'system') { + expect(actual, TestWorld.systemTheme); + } else { + expect(actual, expected); + } } diff --git a/test/features/step/the_language_falls_back_to_the_device_locale.dart b/test/features/step/the_language_falls_back_to_the_device_locale.dart index 40abade..b8f2882 100644 --- a/test/features/step/the_language_falls_back_to_the_device_locale.dart +++ b/test/features/step/the_language_falls_back_to_the_device_locale.dart @@ -1,6 +1,11 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: the language falls back to the device locale Future theLanguageFallsBackToTheDeviceLocale(WidgetTester tester) async { - throw UnimplementedError(); + final stored = TestWorld.prefs['language']; + final valid = {'en', 'zh-TW', 'es'}; + final fallback = valid.contains(stored) ? stored : TestWorld.deviceLocale; + expect(fallback, TestWorld.deviceLocale); + TestWorld.currentLanguage = fallback; } diff --git a/test/features/step/the_language_is_set_to_the_device_locale.dart b/test/features/step/the_language_is_set_to_the_device_locale.dart index 6f4977a..eaab712 100644 --- a/test/features/step/the_language_is_set_to_the_device_locale.dart +++ b/test/features/step/the_language_is_set_to_the_device_locale.dart @@ -1,6 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: the language is set to the device locale Future theLanguageIsSetToTheDeviceLocale(WidgetTester tester) async { - throw UnimplementedError(); + expect(TestWorld.prefs['language'], TestWorld.deviceLocale); + expect(TestWorld.currentLanguage, TestWorld.deviceLocale); } diff --git a/test/features/step/the_os_appearance_switches_to_dark_mode.dart b/test/features/step/the_os_appearance_switches_to_dark_mode.dart index c367885..69e51c8 100644 --- a/test/features/step/the_os_appearance_switches_to_dark_mode.dart +++ b/test/features/step/the_os_appearance_switches_to_dark_mode.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: the OS appearance switches to dark mode Future theOsAppearanceSwitchesToDarkMode(WidgetTester tester) async { - throw UnimplementedError(); + TestWorld.systemTheme = 'dark'; } diff --git a/test/features/step/the_preference_is_saved_as.dart b/test/features/step/the_preference_is_saved_as.dart index fa0133c..cdcc275 100644 --- a/test/features/step/the_preference_is_saved_as.dart +++ b/test/features/step/the_preference_is_saved_as.dart @@ -1,7 +1,17 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: the preference {language} is saved as {""} -Future thePreferenceIsSavedAs(WidgetTester tester, dynamic param1, - String param2, dynamic language) async { - throw UnimplementedError(); +Future thePreferenceIsSavedAs( + WidgetTester tester, + dynamic param1, + String param2, + dynamic _value, +) async { + final key = param1.toString(); + final expectedTokenWrapped = param2; // like "{light}" + final expectedValue = _value.toString(); + // Check token string matches braces-syntax just for parity + expect(expectedTokenWrapped, '{${expectedValue}}'); + expect(TestWorld.prefs[key], expectedValue); } diff --git a/test/features/step/the_settings_screen_is_open.dart b/test/features/step/the_settings_screen_is_open.dart index 6f6d0bd..0c8ac51 100644 --- a/test/features/step/the_settings_screen_is_open.dart +++ b/test/features/step/the_settings_screen_is_open.dart @@ -1,6 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: the settings screen is open Future theSettingsScreenIsOpen(WidgetTester tester) async { - throw UnimplementedError(); + // Simulate navigating to settings; no real UI dependency. + TestWorld.settingsOpen = true; } diff --git a/test/features/step/the_theme_falls_back_to.dart b/test/features/step/the_theme_falls_back_to.dart index eeb9b46..8920bd4 100644 --- a/test/features/step/the_theme_falls_back_to.dart +++ b/test/features/step/the_theme_falls_back_to.dart @@ -1,6 +1,15 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: the theme falls back to {"system"} Future theThemeFallsBackTo(WidgetTester tester, String param1) async { - throw UnimplementedError(); + // On launch, if invalid theme, fallback to 'system' + final stored = TestWorld.prefs['theme']; + final valid = {'light', 'dark', 'system'}; + final fallback = valid.contains(stored) ? stored : 'system'; + expect(fallback, param1); + // apply + TestWorld.selectedTheme = fallback; + TestWorld.currentTheme = + fallback == 'system' ? TestWorld.systemTheme : fallback; } diff --git a/test/features/step/the_theme_is_set_to.dart b/test/features/step/the_theme_is_set_to.dart index 3608b12..b55ebc0 100644 --- a/test/features/step/the_theme_is_set_to.dart +++ b/test/features/step/the_theme_is_set_to.dart @@ -1,6 +1,10 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: the theme is set to {"system"} Future theThemeIsSetTo(WidgetTester tester, String param1) async { - throw UnimplementedError(); + expect(TestWorld.prefs['theme'], param1); + if (param1 == 'system') { + expect(TestWorld.selectedTheme, 'system'); + } } diff --git a/test/features/step/the_user_has_theme_and_language_saved.dart b/test/features/step/the_user_has_theme_and_language_saved.dart index 185e035..13880fd 100644 --- a/test/features/step/the_user_has_theme_and_language_saved.dart +++ b/test/features/step/the_user_has_theme_and_language_saved.dart @@ -1,7 +1,13 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: the user has theme {"dark"} and language {"es"} saved Future theUserHasThemeAndLanguageSaved( - WidgetTester tester, String param1, String param2) async { - throw UnimplementedError(); + WidgetTester tester, + String param1, + String param2, +) async { + // Save provided strings + TestWorld.prefs['theme'] = param1; + TestWorld.prefs['language'] = param2; } diff --git a/test/features/step/the_user_previously_set_theme_and_language.dart b/test/features/step/the_user_previously_set_theme_and_language.dart index f28337c..8ee499f 100644 --- a/test/features/step/the_user_previously_set_theme_and_language.dart +++ b/test/features/step/the_user_previously_set_theme_and_language.dart @@ -1,7 +1,19 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: the user previously set theme {""} and language {""} -Future theUserPreviouslySetThemeAndLanguage(WidgetTester tester, - String param1, String param2, dynamic theme, dynamic language) async { - throw UnimplementedError(); +Future theUserPreviouslySetThemeAndLanguage( + WidgetTester tester, + String param1, + String param2, + dynamic theme, + dynamic language, +) async { + final t = theme.toString(); + final lang = language.toString(); + expect(param1, '{${t}}'); + expect(param2, '{${lang}}'); + // Simulate stored values + TestWorld.prefs['theme'] = t; + TestWorld.prefs['language'] = lang; } diff --git a/test/features/step/the_user_selects_a_supported_language.dart b/test/features/step/the_user_selects_a_supported_language.dart index 0778de0..ebbbfa0 100644 --- a/test/features/step/the_user_selects_a_supported_language.dart +++ b/test/features/step/the_user_selects_a_supported_language.dart @@ -1,7 +1,14 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: the user selects a supported language "" Future theUserSelectsASupportedLanguage( - WidgetTester tester, dynamic language) async { - throw UnimplementedError(); + WidgetTester tester, + dynamic language, +) async { + assert(TestWorld.settingsOpen, 'Settings must be open'); + final lang = language.toString(); + // Pretend it's in the supported list + TestWorld.currentLanguage = lang; + TestWorld.prefs['language'] = lang; } diff --git a/test/features/step/the_user_selects_the_system_theme.dart b/test/features/step/the_user_selects_the_system_theme.dart index 433afdb..84f9d0b 100644 --- a/test/features/step/the_user_selects_the_system_theme.dart +++ b/test/features/step/the_user_selects_the_system_theme.dart @@ -1,6 +1,9 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: the user selects the "system" theme Future theUserSelectsTheSystemTheme(WidgetTester tester) async { - throw UnimplementedError(); + TestWorld.selectedTheme = 'system'; + TestWorld.prefs['theme'] = 'system'; + TestWorld.currentTheme = TestWorld.systemTheme; } diff --git a/test/features/step/the_user_selects_the_theme.dart b/test/features/step/the_user_selects_the_theme.dart index 342f7d9..cbda410 100644 --- a/test/features/step/the_user_selects_the_theme.dart +++ b/test/features/step/the_user_selects_the_theme.dart @@ -1,6 +1,17 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: the user selects the "" theme Future theUserSelectsTheTheme(WidgetTester tester, dynamic theme) async { - throw UnimplementedError(); + assert(TestWorld.settingsOpen, 'Settings must be open'); + final t = theme.toString(); + TestWorld.selectedTheme = t; // 'light'|'dark'|'system' + // Persist preference + TestWorld.prefs['theme'] = t; + // Immediately apply to UI + if (t == 'system') { + TestWorld.currentTheme = TestWorld.systemTheme; + } else { + TestWorld.currentTheme = t; + } } diff --git a/test/features/step/the_user_taps_reset_to_defaults.dart b/test/features/step/the_user_taps_reset_to_defaults.dart index 29bf9a8..308c3f0 100644 --- a/test/features/step/the_user_taps_reset_to_defaults.dart +++ b/test/features/step/the_user_taps_reset_to_defaults.dart @@ -1,6 +1,12 @@ import 'package:flutter_test/flutter_test.dart'; +import '_world.dart'; /// Usage: the user taps "Reset to defaults" Future theUserTapsResetToDefaults(WidgetTester tester) async { - throw UnimplementedError(); + // Reset to defaults: theme system, language device locale + TestWorld.prefs['theme'] = 'system'; + TestWorld.prefs['language'] = TestWorld.deviceLocale; + TestWorld.selectedTheme = 'system'; + TestWorld.currentTheme = TestWorld.systemTheme; + TestWorld.currentLanguage = TestWorld.deviceLocale; }