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).
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)

View file

@ -1,4 +1,6 @@
<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
android:label="Shuffle"
android:name="${applicationName}"

View file

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

View file

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

View file

@ -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 =

View file

@ -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<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}) {
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) {

View file

@ -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 {

View file

@ -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<BorderSide?>(
(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,
),
);
}

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.
```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

View file

@ -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);

View file

@ -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 = <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', () {
expect(theme.materialTapTargetSize, MaterialTapTargetSize.padded);
final styles = [

View file

@ -1,5 +1,6 @@
<!DOCTYPE html>
<html>
<html lang="en">
<!-- lang is a static fallback; the app sets the actual locale at runtime (l10n). -->
<head>
<!--
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`,
`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`.
- 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 |
| `AppRadius` sm / md / lg / xl | 8 / 12 / 16 / 28 |
| 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
wie eine gestreckte Handy-App wirken: Inhalt zentrieren und auf `maxContentWidth` begrenzen.