fix(app): address review findings for foundation
- 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:
parent
568f425ef7
commit
b03d771869
14 changed files with 93 additions and 15 deletions
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,7 @@
|
||||||
hasScannedForEncodings = 0;
|
hasScannedForEncodings = 0;
|
||||||
knownRegions = (
|
knownRegions = (
|
||||||
en,
|
en,
|
||||||
|
de,
|
||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = 97C146E51CF9000F007C117D;
|
mainGroup = 97C146E51CF9000F007C117D;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>[];
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue