fix(app): address review findings for foundation
Some checks failed
CI / flutter (pull_request) Successful in 2m29s
CI / web-build (pull_request) Successful in 53s
CI / supabase (pull_request) Successful in 3s
CI / supabase-tests (pull_request) Failing after 23s

- add INTERNET permission, iOS localizations, html lang
- make focus ring visible on FilledButton
- neutral SITE_URL default, idempotent logger configure

Refs #31

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-02 20:36:58 +01:00
parent 568f425ef7
commit b03d771869
14 changed files with 93 additions and 15 deletions

View file

@ -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). 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) ## Platzhalter-Identität (vor dem Store-Release festlegen)

View file

@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Required in release builds (the debug manifest adds it only for debug/profile). -->
<uses-permission android:name="android.permission.INTERNET"/>
<application <application
android:label="Shuffle" android:label="Shuffle"
android:name="${applicationName}" android:name="${applicationName}"

View file

@ -197,6 +197,7 @@
hasScannedForEncodings = 0; hasScannedForEncodings = 0;
knownRegions = ( knownRegions = (
en, en,
de,
Base, Base,
); );
mainGroup = 97C146E51CF9000F007C117D; mainGroup = 97C146E51CF9000F007C117D;

View file

@ -4,6 +4,11 @@
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>CFBundleLocalizations</key>
<array>
<string>de</string>
<string>en</string>
</array>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>

View file

@ -61,7 +61,7 @@ class AppConfig {
// Platzhalter (Spec #1 § 5.1), bis Chris die echten Werte festlegt. // Platzhalter (Spec #1 § 5.1), bis Chris die echten Werte festlegt.
static const defaultEnvironment = 'dev'; static const defaultEnvironment = 'dev';
static const defaultSiteUrl = 'https://shuffle.cshomelabs.work'; static const defaultSiteUrl = 'http://localhost:3000';
static const defaultLegalPrivacyUrl = static const defaultLegalPrivacyUrl =
'https://shuffle.cshomelabs.work/legal/privacy'; 'https://shuffle.cshomelabs.work/legal/privacy';
static const defaultLegalTermsUrl = static const defaultLegalTermsUrl =

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:developer' as developer; import 'dart:developer' as developer;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -22,10 +23,29 @@ class AppLogger {
void error(String message, {Object? error, StackTrace? stackTrace}) => void error(String message, {Object? error, StackTrace? stackTrace}) =>
_logger.severe(message, error, stackTrace); _logger.severe(message, error, stackTrace);
/// Richtet die Ausgabe einmalig ein. Release: ab INFO, sonst alles. static StreamSubscription<LogRecord>? _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}) { static void configure({Level? level}) {
Logger.root.level = level ?? (kReleaseMode ? Level.INFO : Level.ALL); 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<LogRecord>? get subscription => _subscription;
/// Entfernt den Listener wieder (nur für Tests).
@visibleForTesting
static Future<void> resetForTesting() async {
await _subscription?.cancel();
_subscription = null;
} }
static void _write(LogRecord record) { static void _write(LogRecord record) {

View file

@ -44,6 +44,9 @@ abstract final class AppBreakpoints {
/// Max content width for forms/text on wide screens. /// Max content width for forms/text on wide screens.
static const double maxContentWidth = 560; 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 { enum AppWidthClass {

View file

@ -43,6 +43,13 @@ abstract final class AppTheme {
? BorderSide(color: scheme.primary, width: 3) ? BorderSide(color: scheme.primary, width: 3)
: null, : 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<BorderSide?>(
(states) => states.contains(WidgetState.focused)
? BorderSide(color: scheme.onSurface, width: 3)
: null,
);
OutlineInputBorder border(Color color, double width) => OutlineInputBorder( OutlineInputBorder border(Color color, double width) => OutlineInputBorder(
borderRadius: AppRadius.mdAll, borderRadius: AppRadius.mdAll,
@ -77,7 +84,7 @@ abstract final class AppTheme {
minimumSize: minSize, minimumSize: minSize,
shape: shape, shape: shape,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl), padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl),
).copyWith(side: focusSide), ).copyWith(side: filledFocusSide),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@ -141,8 +148,6 @@ abstract final class AppTheme {
shape: RoundedRectangleBorder(borderRadius: AppRadius.smAll), shape: RoundedRectangleBorder(borderRadius: AppRadius.smAll),
showCloseIcon: true, showCloseIcon: true,
closeIconColor: scheme.onInverseSurface, closeIconColor: scheme.onInverseSurface,
// Wide screens: do not stretch across the whole window.
width: null,
), ),
); );
} }

View file

@ -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. und Web-E2E (Playwright) kommen mit den jeweiligen Feature-Tasks.
```bash ```bash
flutter test --coverage flutter test
``` ```
Coverage wird in der CI derzeit nicht erhoben; lokal optional `flutter test --coverage`.
## Helfer (`test/helpers/`) ## Helfer (`test/helpers/`)
| Datei | Inhalt | | 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: (Story-ID, Kriterium-Nummer wie im Issue) am Anfang des Namens:
```dart ```dart
group('KTO-01 Kriterium 3: Passwort-Richtlinie', () { group('KTO-01 Kriterium 3: password policy', () {
test('lehnt Passwörter unter 10 Zeichen ab', () { … }); 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 - Eine Referenz pro `group`/Test; deckt ein Test mehrere Kriterien ab, alle nennen

View file

@ -32,7 +32,8 @@ void main() {
}); });
expect(config.environment, AppConfig.defaultEnvironment); 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.legalPrivacyUrl, AppConfig.defaultLegalPrivacyUrl);
expect(config.legalTermsUrl, AppConfig.defaultLegalTermsUrl); expect(config.legalTermsUrl, AppConfig.defaultLegalTermsUrl);
expect(config.termsVersion, AppConfig.defaultTermsVersion); expect(config.termsVersion, AppConfig.defaultTermsVersion);

View file

@ -3,6 +3,18 @@ import 'package:logging/logging.dart';
import 'package:shuffle/core/logging/app_logger.dart'; import 'package:shuffle/core/logging/app_logger.dart';
void main() { 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', () { test('AppLogger forwards records to package:logging', () {
Logger.root.level = Level.ALL; Logger.root.level = Level.ALL;
final records = <LogRecord>[]; final records = <LogRecord>[];

View file

@ -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', () { test('touch targets are padded and buttons are >= 48dp', () {
expect(theme.materialTapTargetSize, MaterialTapTargetSize.padded); expect(theme.materialTapTargetSize, MaterialTapTargetSize.padded);
final styles = [ final styles = [

View file

@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<!-- lang is a static fallback; the app sets the actual locale at runtime (l10n). -->
<head> <head>
<!-- <!--
If you are serving your web app in a path other than the root, change the If you are serving your web app in a path other than the root, change the

View file

@ -34,7 +34,7 @@ müssen in den Test aufgenommen werden. Deaktivierte Elemente sind von der Kontr
- Mindestgröße 48 x 48 dp für alle Buttons und Icon-Buttons (`minimumSize`), `MaterialTapTargetSize.padded`, - Mindestgröße 48 x 48 dp für alle Buttons und Icon-Buttons (`minimumSize`), `MaterialTapTargetSize.padded`,
`VisualDensity.standard`. Nicht auf `compact` verkleinern. `VisualDensity.standard`. Nicht auf `compact` verkleinern.
- Tastaturfokus (Web/Desktop): Buttons und Textfelder zeigen einen 3 dp breiten Rahmen in `primary`. - Tastaturfokus (Web/Desktop): Textfelder, Outlined-/Elevated-/Text-Buttons zeigen einen 3 dp breiten Rahmen in `primary`; der FilledButton (Füllung `primary`) nutzt stattdessen `onSurface`, weil `primary` auf der Füllung unsichtbar wäre (Kontrast zu Fläche und Füllung >= 3:1, im Test geprüft).
- Icons ohne Text brauchen `tooltip` bzw. `Semantics(label: ...)`; Bilder ein Label oder `excludeSemantics`. - Icons ohne Text brauchen `tooltip` bzw. `Semantics(label: ...)`; Bilder ein Label oder `excludeSemantics`.
- Text muss bis 200 % Skalierung ohne Abschneiden funktionieren (kein fixes Höhen-Layout für Text). - Text muss bis 200 % Skalierung ohne Abschneiden funktionieren (kein fixes Höhen-Layout für Text).
@ -50,7 +50,8 @@ Hinweise `bodySmall` (`onSurfaceVariant`). Schriftgrößen nie hart codieren.
| `AppSpacing` xxs / xs / sm / md / lg / xl / xxl / xxxl | 2 / 4 / 8 / 12 / 16 / 24 / 32 / 48 | | `AppSpacing` xxs / xs / sm / md / lg / xl / xxl / xxxl | 2 / 4 / 8 / 12 / 16 / 24 / 32 / 48 |
| `AppRadius` sm / md / lg / xl | 8 / 12 / 16 / 28 | | `AppRadius` sm / md / lg / xl | 8 / 12 / 16 / 28 |
| Breakpoints (`AppWidthClass`) | kompakt < 600, mittel 600-1023, groß >= 1024 | | Breakpoints (`AppWidthClass`) | kompakt < 600, mittel 600-1023, groß >= 1024 |
| `AppBreakpoints.maxContentWidth` | 560 (Formulare/Text auf breiten Bildschirmen begrenzen) | | `AppBreakpoints.maxContentWidth` | 560 (allgemeine Formulare/Text auf breiten Bildschirmen begrenzen) |
| `AppBreakpoints.authCardMaxWidth` | 480 (zentrierte Auth-Karte ab 600 dp, Spec #1 § 6) |
`AppSpacing.pagePadding(width)` liefert den Seitenrand je Breitenklasse (16 / 24 / 32). Web darf nicht `AppSpacing.pagePadding(width)` liefert den Seitenrand je Breitenklasse (16 / 24 / 32). Web darf nicht
wie eine gestreckte Handy-App wirken: Inhalt zentrieren und auf `maxContentWidth` begrenzen. wie eine gestreckte Handy-App wirken: Inhalt zentrieren und auf `maxContentWidth` begrenzen.