From 51738f78bcc07447c8f038fdf95d9df8ceab65a2 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 15 Mar 2026 19:59:44 +0100 Subject: [PATCH] feat(01-01): add core infrastructure, localization, and Wave 0 tests - AppDatabase with schemaVersion 1, in-memory executor for testing - Database provider with @Riverpod(keepAlive: true) - AppTheme with sage green seed ColorScheme and warm surface overrides - ThemeNotifier with SharedPreferences persistence, defaults to system - Full German ARB localization (15 keys) with proper umlauts - Minimal main.dart with ProviderScope placeholder - Drift schema v1 captured via make-migrations - All .g.dart files generated via build_runner - Wave 0 tests: database (3), color scheme (6), theme (3), localization (2) -- 14 total, all passing Co-Authored-By: Claude Opus 4.6 --- .../household_keeper/drift_schema_v1.json | 10 + lib/core/database/database.dart | 22 ++ lib/core/database/database.g.dart | 19 ++ lib/core/providers/database_provider.dart | 11 + lib/core/providers/database_provider.g.dart | 51 ++++ lib/core/theme/app_theme.dart | 45 ++++ lib/core/theme/theme_provider.dart | 53 ++++ lib/core/theme/theme_provider.g.dart | 62 +++++ lib/l10n/app_de.arb | 25 +- lib/l10n/app_localizations.dart | 242 ++++++++++++++++++ lib/l10n/app_localizations_de.dart | 70 +++++ lib/main.dart | 120 +-------- test/core/database/database_test.dart | 31 +++ test/core/theme/color_scheme_test.dart | 45 ++++ test/core/theme/theme_test.dart | 41 +++ test/l10n/localization_test.dart | 56 ++++ test/widget_test.dart | 30 --- 17 files changed, 784 insertions(+), 149 deletions(-) create mode 100644 drift_schemas/household_keeper/drift_schema_v1.json create mode 100644 lib/core/database/database.dart create mode 100644 lib/core/database/database.g.dart create mode 100644 lib/core/providers/database_provider.dart create mode 100644 lib/core/providers/database_provider.g.dart create mode 100644 lib/core/theme/app_theme.dart create mode 100644 lib/core/theme/theme_provider.dart create mode 100644 lib/core/theme/theme_provider.g.dart create mode 100644 lib/l10n/app_localizations.dart create mode 100644 lib/l10n/app_localizations_de.dart create mode 100644 test/core/database/database_test.dart create mode 100644 test/core/theme/color_scheme_test.dart create mode 100644 test/core/theme/theme_test.dart create mode 100644 test/l10n/localization_test.dart delete mode 100644 test/widget_test.dart diff --git a/drift_schemas/household_keeper/drift_schema_v1.json b/drift_schemas/household_keeper/drift_schema_v1.json new file mode 100644 index 0000000..d5df84b --- /dev/null +++ b/drift_schemas/household_keeper/drift_schema_v1.json @@ -0,0 +1,10 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [] +} \ No newline at end of file diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart new file mode 100644 index 0000000..6169396 --- /dev/null +++ b/lib/core/database/database.dart @@ -0,0 +1,22 @@ +import 'package:drift/drift.dart'; +import 'package:drift_flutter/drift_flutter.dart'; +import 'package:path_provider/path_provider.dart'; + +part 'database.g.dart'; + +@DriftDatabase(tables: []) +class AppDatabase extends _$AppDatabase { + AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); + + @override + int get schemaVersion => 1; + + static QueryExecutor _openConnection() { + return driftDatabase( + name: 'household_keeper', + native: const DriftNativeOptions( + databaseDirectory: getApplicationSupportDirectory, + ), + ); + } +} diff --git a/lib/core/database/database.g.dart b/lib/core/database/database.g.dart new file mode 100644 index 0000000..40c484b --- /dev/null +++ b/lib/core/database/database.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ignore_for_file: type=lint +abstract class _$AppDatabase extends GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => []; +} + +class $AppDatabaseManager { + final _$AppDatabase _db; + $AppDatabaseManager(this._db); +} diff --git a/lib/core/providers/database_provider.dart b/lib/core/providers/database_provider.dart new file mode 100644 index 0000000..6aefb57 --- /dev/null +++ b/lib/core/providers/database_provider.dart @@ -0,0 +1,11 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:household_keeper/core/database/database.dart'; + +part 'database_provider.g.dart'; + +@Riverpod(keepAlive: true) +AppDatabase appDatabase(Ref ref) { + final db = AppDatabase(); + ref.onDispose(db.close); + return db; +} diff --git a/lib/core/providers/database_provider.g.dart b/lib/core/providers/database_provider.g.dart new file mode 100644 index 0000000..25ded9a --- /dev/null +++ b/lib/core/providers/database_provider.g.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(appDatabase) +final appDatabaseProvider = AppDatabaseProvider._(); + +final class AppDatabaseProvider + extends $FunctionalProvider + with $Provider { + AppDatabaseProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'appDatabaseProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$appDatabaseHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + AppDatabase create(Ref ref) { + return appDatabase(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AppDatabase value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$appDatabaseHash() => r'59cce38d45eeaba199eddd097d8e149d66f9f3e1'; diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..82f16bb --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + AppTheme._(); + + static ThemeData lightTheme() { + final colorScheme = ColorScheme.fromSeed( + seedColor: const Color(0xFF7A9A6D), + brightness: Brightness.light, + dynamicSchemeVariant: DynamicSchemeVariant.tonalSpot, + ).copyWith( + surface: const Color(0xFFF5F0E8), + surfaceContainerLowest: const Color(0xFFFAF7F2), + surfaceContainerLow: const Color(0xFFF2EDE4), + surfaceContainer: const Color(0xFFEDE7DC), + surfaceContainerHigh: const Color(0xFFE7E0D5), + surfaceContainerHighest: const Color(0xFFE0D9CE), + ); + + return ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + ); + } + + static ThemeData darkTheme() { + final colorScheme = ColorScheme.fromSeed( + seedColor: const Color(0xFF7A9A6D), + brightness: Brightness.dark, + dynamicSchemeVariant: DynamicSchemeVariant.tonalSpot, + ).copyWith( + surface: const Color(0xFF2A2520), + surfaceContainerLowest: const Color(0xFF1E1A16), + surfaceContainerLow: const Color(0xFF322D27), + surfaceContainer: const Color(0xFF3A342E), + surfaceContainerHigh: const Color(0xFF433D36), + surfaceContainerHighest: const Color(0xFF4D463F), + ); + + return ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + ); + } +} diff --git a/lib/core/theme/theme_provider.dart b/lib/core/theme/theme_provider.dart new file mode 100644 index 0000000..aeaa1cb --- /dev/null +++ b/lib/core/theme/theme_provider.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +part 'theme_provider.g.dart'; + +const _themeModeKey = 'theme_mode'; + +@riverpod +class ThemeNotifier extends _$ThemeNotifier { + @override + ThemeMode build() { + _loadPersistedThemeMode(); + return ThemeMode.system; + } + + Future _loadPersistedThemeMode() async { + final prefs = await SharedPreferences.getInstance(); + final persisted = prefs.getString(_themeModeKey); + if (persisted != null) { + state = _themeModeFromString(persisted); + } + } + + Future setThemeMode(ThemeMode mode) async { + state = mode; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_themeModeKey, _themeModeToString(mode)); + } + + static ThemeMode _themeModeFromString(String? value) { + switch (value) { + case 'light': + return ThemeMode.light; + case 'dark': + return ThemeMode.dark; + case 'system': + default: + return ThemeMode.system; + } + } + + static String _themeModeToString(ThemeMode mode) { + switch (mode) { + case ThemeMode.light: + return 'light'; + case ThemeMode.dark: + return 'dark'; + case ThemeMode.system: + return 'system'; + } + } +} diff --git a/lib/core/theme/theme_provider.g.dart b/lib/core/theme/theme_provider.g.dart new file mode 100644 index 0000000..1812643 --- /dev/null +++ b/lib/core/theme/theme_provider.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'theme_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(ThemeNotifier) +final themeProvider = ThemeNotifierProvider._(); + +final class ThemeNotifierProvider + extends $NotifierProvider { + ThemeNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'themeProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$themeNotifierHash(); + + @$internal + @override + ThemeNotifier create() => ThemeNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ThemeMode value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$themeNotifierHash() => r'060f7d104905995da8c785d2f50f15a4db2d7022'; + +abstract class _$ThemeNotifier extends $Notifier { + ThemeMode build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + ThemeMode, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 3ccc3ba..8744061 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,4 +1,27 @@ { "@@locale": "de", - "appTitle": "HouseHoldKeaper" + "appTitle": "HouseHoldKeaper", + "tabHome": "\u00dcbersicht", + "tabRooms": "R\u00e4ume", + "tabSettings": "Einstellungen", + "homeEmptyTitle": "Noch nichts zu tun!", + "homeEmptyMessage": "Lege zuerst einen Raum an, um Aufgaben zu planen.", + "homeEmptyAction": "Raum erstellen", + "roomsEmptyTitle": "Hier ist noch alles leer!", + "roomsEmptyMessage": "Erstelle deinen ersten Raum, um loszulegen.", + "roomsEmptyAction": "Raum erstellen", + "settingsSectionAppearance": "Darstellung", + "settingsThemeLabel": "Farbschema", + "themeSystem": "System", + "themeLight": "Hell", + "themeDark": "Dunkel", + "settingsSectionAbout": "\u00dcber", + "aboutAppName": "HouseHoldKeaper", + "aboutTagline": "Dein Haushalt, entspannt organisiert.", + "aboutVersion": "Version {version}", + "@aboutVersion": { + "placeholders": { + "version": { "type": "String" } + } + } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..7f3cd6c --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,242 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_de.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations of(BuildContext context) { + return Localizations.of(context, AppLocalizations)!; + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [Locale('de')]; + + /// No description provided for @appTitle. + /// + /// In de, this message translates to: + /// **'HouseHoldKeaper'** + String get appTitle; + + /// No description provided for @tabHome. + /// + /// In de, this message translates to: + /// **'Übersicht'** + String get tabHome; + + /// No description provided for @tabRooms. + /// + /// In de, this message translates to: + /// **'Räume'** + String get tabRooms; + + /// No description provided for @tabSettings. + /// + /// In de, this message translates to: + /// **'Einstellungen'** + String get tabSettings; + + /// No description provided for @homeEmptyTitle. + /// + /// In de, this message translates to: + /// **'Noch nichts zu tun!'** + String get homeEmptyTitle; + + /// No description provided for @homeEmptyMessage. + /// + /// In de, this message translates to: + /// **'Lege zuerst einen Raum an, um Aufgaben zu planen.'** + String get homeEmptyMessage; + + /// No description provided for @homeEmptyAction. + /// + /// In de, this message translates to: + /// **'Raum erstellen'** + String get homeEmptyAction; + + /// No description provided for @roomsEmptyTitle. + /// + /// In de, this message translates to: + /// **'Hier ist noch alles leer!'** + String get roomsEmptyTitle; + + /// No description provided for @roomsEmptyMessage. + /// + /// In de, this message translates to: + /// **'Erstelle deinen ersten Raum, um loszulegen.'** + String get roomsEmptyMessage; + + /// No description provided for @roomsEmptyAction. + /// + /// In de, this message translates to: + /// **'Raum erstellen'** + String get roomsEmptyAction; + + /// No description provided for @settingsSectionAppearance. + /// + /// In de, this message translates to: + /// **'Darstellung'** + String get settingsSectionAppearance; + + /// No description provided for @settingsThemeLabel. + /// + /// In de, this message translates to: + /// **'Farbschema'** + String get settingsThemeLabel; + + /// No description provided for @themeSystem. + /// + /// In de, this message translates to: + /// **'System'** + String get themeSystem; + + /// No description provided for @themeLight. + /// + /// In de, this message translates to: + /// **'Hell'** + String get themeLight; + + /// No description provided for @themeDark. + /// + /// In de, this message translates to: + /// **'Dunkel'** + String get themeDark; + + /// No description provided for @settingsSectionAbout. + /// + /// In de, this message translates to: + /// **'Über'** + String get settingsSectionAbout; + + /// No description provided for @aboutAppName. + /// + /// In de, this message translates to: + /// **'HouseHoldKeaper'** + String get aboutAppName; + + /// No description provided for @aboutTagline. + /// + /// In de, this message translates to: + /// **'Dein Haushalt, entspannt organisiert.'** + String get aboutTagline; + + /// No description provided for @aboutVersion. + /// + /// In de, this message translates to: + /// **'Version {version}'** + String aboutVersion(String version); +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['de'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'de': + return AppLocalizationsDe(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart new file mode 100644 index 0000000..4ca24de --- /dev/null +++ b/lib/l10n/app_localizations_de.dart @@ -0,0 +1,70 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for German (`de`). +class AppLocalizationsDe extends AppLocalizations { + AppLocalizationsDe([String locale = 'de']) : super(locale); + + @override + String get appTitle => 'HouseHoldKeaper'; + + @override + String get tabHome => 'Übersicht'; + + @override + String get tabRooms => 'Räume'; + + @override + String get tabSettings => 'Einstellungen'; + + @override + String get homeEmptyTitle => 'Noch nichts zu tun!'; + + @override + String get homeEmptyMessage => + 'Lege zuerst einen Raum an, um Aufgaben zu planen.'; + + @override + String get homeEmptyAction => 'Raum erstellen'; + + @override + String get roomsEmptyTitle => 'Hier ist noch alles leer!'; + + @override + String get roomsEmptyMessage => 'Erstelle deinen ersten Raum, um loszulegen.'; + + @override + String get roomsEmptyAction => 'Raum erstellen'; + + @override + String get settingsSectionAppearance => 'Darstellung'; + + @override + String get settingsThemeLabel => 'Farbschema'; + + @override + String get themeSystem => 'System'; + + @override + String get themeLight => 'Hell'; + + @override + String get themeDark => 'Dunkel'; + + @override + String get settingsSectionAbout => 'Über'; + + @override + String get aboutAppName => 'HouseHoldKeaper'; + + @override + String get aboutTagline => 'Dein Haushalt, entspannt organisiert.'; + + @override + String aboutVersion(String version) { + return 'Version $version'; + } +} diff --git a/lib/main.dart b/lib/main.dart index 244a702..fe30f18 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,122 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; void main() { - runApp(const MyApp()); -} - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - // This widget is the root of your application. - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: .fromSeed(seedColor: Colors.deepPurple), - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: .center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), - ); - } + runApp(const ProviderScope(child: MaterialApp(home: Scaffold()))); } diff --git a/test/core/database/database_test.dart b/test/core/database/database_test.dart new file mode 100644 index 0000000..61f99a8 --- /dev/null +++ b/test/core/database/database_test.dart @@ -0,0 +1,31 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:household_keeper/core/database/database.dart'; + +void main() { + group('AppDatabase', () { + late AppDatabase db; + + setUp(() { + db = AppDatabase(NativeDatabase.memory()); + }); + + tearDown(() async { + await db.close(); + }); + + test('opens successfully with in-memory executor', () { + expect(db, isNotNull); + }); + + test('has schemaVersion 1', () { + expect(db.schemaVersion, equals(1)); + }); + + test('can be closed without error', () async { + await db.close(); + // If we reach here, close succeeded. Re-create for tearDown. + db = AppDatabase(NativeDatabase.memory()); + }); + }); +} diff --git a/test/core/theme/color_scheme_test.dart b/test/core/theme/color_scheme_test.dart new file mode 100644 index 0000000..f87a0a5 --- /dev/null +++ b/test/core/theme/color_scheme_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:household_keeper/core/theme/app_theme.dart'; + +void main() { + group('AppTheme ColorScheme', () { + test('lightTheme has Brightness.light', () { + final theme = AppTheme.lightTheme(); + expect(theme.colorScheme.brightness, equals(Brightness.light)); + }); + + test('darkTheme has Brightness.dark', () { + final theme = AppTheme.darkTheme(); + expect(theme.colorScheme.brightness, equals(Brightness.dark)); + }); + + test('both themes use sage green seed (primary in green hue range)', () { + final lightPrimary = HSLColor.fromColor( + AppTheme.lightTheme().colorScheme.primary, + ); + final darkPrimary = HSLColor.fromColor( + AppTheme.darkTheme().colorScheme.primary, + ); + + // Sage green hue is approximately 100-150 degrees + expect(lightPrimary.hue, inInclusiveRange(80, 160)); + expect(darkPrimary.hue, inInclusiveRange(80, 160)); + }); + + test('light surface color is warm stone (0xFFF5F0E8)', () { + final theme = AppTheme.lightTheme(); + expect(theme.colorScheme.surface, equals(const Color(0xFFF5F0E8))); + }); + + test('dark surface color is warm charcoal (0xFF2A2520), not cold gray', () { + final theme = AppTheme.darkTheme(); + expect(theme.colorScheme.surface, equals(const Color(0xFF2A2520))); + }); + + test('both themes use Material 3', () { + expect(AppTheme.lightTheme().useMaterial3, isTrue); + expect(AppTheme.darkTheme().useMaterial3, isTrue); + }); + }); +} diff --git a/test/core/theme/theme_test.dart b/test/core/theme/theme_test.dart new file mode 100644 index 0000000..4807522 --- /dev/null +++ b/test/core/theme/theme_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:household_keeper/core/theme/theme_provider.dart'; + +void main() { + group('ThemeNotifier', () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('defaults to ThemeMode.system', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final themeMode = container.read(themeProvider); + expect(themeMode, equals(ThemeMode.system)); + }); + + test('setThemeMode(dark) updates state to dark', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + await container.read(themeProvider.notifier).setThemeMode( + ThemeMode.dark, + ); + expect(container.read(themeProvider), equals(ThemeMode.dark)); + }); + + test('setThemeMode(light) updates state to light', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + await container.read(themeProvider.notifier).setThemeMode( + ThemeMode.light, + ); + expect(container.read(themeProvider), equals(ThemeMode.light)); + }); + }); +} diff --git a/test/l10n/localization_test.dart b/test/l10n/localization_test.dart new file mode 100644 index 0000000..eda0b70 --- /dev/null +++ b/test/l10n/localization_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:household_keeper/l10n/app_localizations.dart'; + +void main() { + group('AppLocalizations (German)', () { + late AppLocalizations l10n; + + testWidgets('loads German localization and displays tabHome', + (tester) async { + late AppLocalizations capturedL10n; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: const [Locale('de')], + locale: const Locale('de'), + home: Builder( + builder: (context) { + capturedL10n = AppLocalizations.of(context); + return Text(capturedL10n.tabHome); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + // Verify the rendered text contains the expected German string + expect(find.text('\u00dcbersicht'), findsOneWidget); + }); + + testWidgets('all critical keys are non-empty', (tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: const [Locale('de')], + locale: const Locale('de'), + home: Builder( + builder: (context) { + l10n = AppLocalizations.of(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(l10n.appTitle, isNotEmpty); + expect(l10n.tabHome, isNotEmpty); + expect(l10n.tabRooms, isNotEmpty); + expect(l10n.tabSettings, isNotEmpty); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index e9b072f..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:household_keeper/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -}