feat(app): App-Fundament – Flutter-Gerüst und Supabase-Konfiguration #32
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).
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -197,6 +197,7 @@
|
|||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
de,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>[];
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue