diff --git a/app/README.md b/app/README.md index 9ffb89e..4bda7de 100644 --- a/app/README.md +++ b/app/README.md @@ -24,7 +24,7 @@ flutter run -d chrome --dart-define-from-file=env/dev.json ``` Pflichtwerte: `SUPABASE_URL`, `SUPABASE_ANON_KEY` (der Start bricht ohne sie ab). -Optional mit Platzhalter: `SITE_URL`, `LEGAL_PRIVACY_URL`, `LEGAL_TERMS_URL`, `TERMS_VERSION`, `APP_ENV`. +Optional mit Platzhalter: `SITE_URL` (Default `http://localhost:3000`; für Staging/Prod zwingend setzen, sonst zeigen Mail-Links auf localhost), `LEGAL_PRIVACY_URL`, `LEGAL_TERMS_URL`, `TERMS_VERSION`, `APP_ENV`. ## Platzhalter-Identität (vor dem Store-Release festlegen) diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 7ab87d7..41e93cb 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + CADisableMinimumFrameDurationOnPhone + CFBundleLocalizations + + de + en + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName diff --git a/app/lib/core/config/app_config.dart b/app/lib/core/config/app_config.dart index da64f52..a1108bb 100644 --- a/app/lib/core/config/app_config.dart +++ b/app/lib/core/config/app_config.dart @@ -61,7 +61,7 @@ class AppConfig { // Platzhalter (Spec #1 § 5.1), bis Chris die echten Werte festlegt. static const defaultEnvironment = 'dev'; - static const defaultSiteUrl = 'https://shuffle.cshomelabs.work'; + static const defaultSiteUrl = 'http://localhost:3000'; static const defaultLegalPrivacyUrl = 'https://shuffle.cshomelabs.work/legal/privacy'; static const defaultLegalTermsUrl = diff --git a/app/lib/core/logging/app_logger.dart b/app/lib/core/logging/app_logger.dart index 263d20d..a10f4d6 100644 --- a/app/lib/core/logging/app_logger.dart +++ b/app/lib/core/logging/app_logger.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:developer' as developer; import 'package:flutter/foundation.dart'; @@ -22,10 +23,29 @@ class AppLogger { void error(String message, {Object? error, StackTrace? stackTrace}) => _logger.severe(message, error, stackTrace); - /// Richtet die Ausgabe einmalig ein. Release: ab INFO, sonst alles. + static StreamSubscription? _subscription; + + /// Richtet die Ausgabe ein. Release: ab INFO, sonst alles. + /// + /// Idempotent: Wiederholte Aufrufe aktualisieren nur den Level und + /// registrieren keinen weiteren Listener. + /// + /// Hinweis: `developer.log` hat in Release-Builds praktisch keine sichtbare + /// Wirkung; produktives Crash-/Error-Reporting folgt in einem eigenen Task. static void configure({Level? level}) { Logger.root.level = level ?? (kReleaseMode ? Level.INFO : Level.ALL); - Logger.root.onRecord.listen(_write); + _subscription ??= Logger.root.onRecord.listen(_write); + } + + /// Aktiver Listener (nur für Tests). + @visibleForTesting + static StreamSubscription? get subscription => _subscription; + + /// Entfernt den Listener wieder (nur für Tests). + @visibleForTesting + static Future resetForTesting() async { + await _subscription?.cancel(); + _subscription = null; } static void _write(LogRecord record) { diff --git a/app/lib/core/theme/app_spacing.dart b/app/lib/core/theme/app_spacing.dart index 0ced66e..5035708 100644 --- a/app/lib/core/theme/app_spacing.dart +++ b/app/lib/core/theme/app_spacing.dart @@ -44,6 +44,9 @@ abstract final class AppBreakpoints { /// Max content width for forms/text on wide screens. static const double maxContentWidth = 560; + + /// Max width of the centered auth card (Spec #1 § 6) from the medium class on. + static const double authCardMaxWidth = 480; } enum AppWidthClass { diff --git a/app/lib/core/theme/app_theme.dart b/app/lib/core/theme/app_theme.dart index 08e82d6..289994e 100644 --- a/app/lib/core/theme/app_theme.dart +++ b/app/lib/core/theme/app_theme.dart @@ -43,6 +43,13 @@ abstract final class AppTheme { ? BorderSide(color: scheme.primary, width: 3) : null, ); + // The primary ring is invisible on the primary fill of a FilledButton; + // onSurface contrasts with both the fill and the surrounding surface. + final filledFocusSide = WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.focused) + ? BorderSide(color: scheme.onSurface, width: 3) + : null, + ); OutlineInputBorder border(Color color, double width) => OutlineInputBorder( borderRadius: AppRadius.mdAll, @@ -77,7 +84,7 @@ abstract final class AppTheme { minimumSize: minSize, shape: shape, padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl), - ).copyWith(side: focusSide), + ).copyWith(side: filledFocusSide), ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( @@ -141,8 +148,6 @@ abstract final class AppTheme { shape: RoundedRectangleBorder(borderRadius: AppRadius.smAll), showCloseIcon: true, closeIconColor: scheme.onInverseSurface, - // Wide screens: do not stretch across the whole window. - width: null, ), ); } diff --git a/app/test/README.md b/app/test/README.md index c15e777..2d897a6 100644 --- a/app/test/README.md +++ b/app/test/README.md @@ -4,9 +4,11 @@ Unit- und Widget-Tests spiegeln die Struktur von `lib/`. Integration (`integrati und Web-E2E (Playwright) kommen mit den jeweiligen Feature-Tasks. ```bash -flutter test --coverage +flutter test ``` +Coverage wird in der CI derzeit nicht erhoben; lokal optional `flutter test --coverage`. + ## Helfer (`test/helpers/`) | Datei | Inhalt | @@ -25,11 +27,11 @@ Tests, die ein Akzeptanzkriterium belegen, tragen die Referenz `KTO-01 Kriterium (Story-ID, Kriterium-Nummer wie im Issue) am Anfang des Namens: ```dart -group('KTO-01 Kriterium 3: Passwort-Richtlinie', () { - test('lehnt Passwörter unter 10 Zeichen ab', () { … }); +group('KTO-01 Kriterium 3: password policy', () { + test('rejects passwords shorter than 10 characters', () { … }); }); -testWidgets('KTO-01 Kriterium 7: zeigt bei falschen Zugangsdaten eine allgemeine Meldung', …); +testWidgets('KTO-01 Kriterium 7: shows a generic message for wrong credentials', …); ``` - Eine Referenz pro `group`/Test; deckt ein Test mehrere Kriterien ab, alle nennen diff --git a/app/test/core/config/app_config_test.dart b/app/test/core/config/app_config_test.dart index 0d6dcee..a11eec7 100644 --- a/app/test/core/config/app_config_test.dart +++ b/app/test/core/config/app_config_test.dart @@ -32,7 +32,8 @@ void main() { }); expect(config.environment, AppConfig.defaultEnvironment); - expect(config.siteUrl, 'https://shuffle.cshomelabs.work'); + expect(config.siteUrl, AppConfig.defaultSiteUrl); + expect(config.siteUrl, 'http://localhost:3000'); expect(config.legalPrivacyUrl, AppConfig.defaultLegalPrivacyUrl); expect(config.legalTermsUrl, AppConfig.defaultLegalTermsUrl); expect(config.termsVersion, AppConfig.defaultTermsVersion); diff --git a/app/test/core/logging/app_logger_test.dart b/app/test/core/logging/app_logger_test.dart index 40eb5eb..baac91c 100644 --- a/app/test/core/logging/app_logger_test.dart +++ b/app/test/core/logging/app_logger_test.dart @@ -3,6 +3,18 @@ import 'package:logging/logging.dart'; import 'package:shuffle/core/logging/app_logger.dart'; void main() { + test('configure is idempotent and registers a single listener', () async { + addTearDown(AppLogger.resetForTesting); + await AppLogger.resetForTesting(); + AppLogger.configure(level: Level.ALL); + final first = AppLogger.subscription; + AppLogger.configure(level: Level.WARNING); + + expect(first, isNotNull); + expect(AppLogger.subscription, same(first)); + expect(Logger.root.level, Level.WARNING); + }); + test('AppLogger forwards records to package:logging', () { Logger.root.level = Level.ALL; final records = []; diff --git a/app/test/core/theme/app_theme_test.dart b/app/test/core/theme/app_theme_test.dart index e08edcc..eebbb75 100644 --- a/app/test/core/theme/app_theme_test.dart +++ b/app/test/core/theme/app_theme_test.dart @@ -91,6 +91,31 @@ void main() { } }); + test('focused FilledButton ring differs from fill and contrasts with surface', () { + final side = theme.filledButtonTheme.style!.side!.resolve({ + WidgetState.focused, + })!; + expect( + side.color, + isNot(s.primary), + reason: 'ring must differ from fill', + ); + expect(side.width, greaterThanOrEqualTo(2)); + expect( + contrast(side.color, s.surface), + greaterThanOrEqualTo(3.0), + reason: 'ring/surface', + ); + // Ring/fill cannot reach 3:1 in both modes with a single color (the dark + // fill is light, the dark surface is dark); the outer half of the ring + // is judged against the surface, the inner half only has to differ. + expect( + theme.filledButtonTheme.style!.side!.resolve({}), + isNull, + reason: 'no border when unfocused', + ); + }); + test('touch targets are padded and buttons are >= 48dp', () { expect(theme.materialTapTargetSize, MaterialTapTargetSize.padded); final styles = [ diff --git a/app/web/index.html b/app/web/index.html index e2bd128..7b7181d 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -1,5 +1,6 @@ - + +