# YouTube-Gast-Zugang (App-Seite) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Gäste ohne Cloud-Account können den YouTube-Downloader/die YT-Suche über einen anonymen, geräte-gebundenen Gast-Token nutzen — mit einem serverseitig durchgesetzten Tages-Limit (5 Downloads), ohne Cookies, nach bewusster Wahl in der UI. **Architecture:** Ein neuer `ChangeNotifier`-Service `GastZugang` (analog zu `BakaAuth`, aber ohne Passwort) hält einen optionalen Gast-Token. Die bestehenden `YtDownloadService`/`YtSearchService` bekommen einen optionalen `gast`-Parameter und wählen pro Anfrage zwischen Baka-Auth-Bearer-Header und `X-Guest-Token`-Header. Die YouTube-Bildschirme zeigen echten Gästen zwei Buttons ("Anmelden" / "Als Gast fortfahren") statt nur einem. **Tech Stack:** Flutter/Dart, `provider` (State), `http` + `http/testing` (Netzwerk/Tests), `flutter_secure_storage` (Token-Ablage, über die bestehende `TokenSpeicher`-Abstraktion aus `baka_auth.dart`), `flutter_test` (Widget-/Unit-Tests). **Spec:** `docs/superpowers/specs/2026-08-26-youtube-guest-quota-design.md` — beschreibt auch den Server-Vertrag (`POST /api/guest-token`, `X-Guest-Token`-Header, `guest_remaining`-Feld, Cookie-Ausschluss für Gäste). Der Server-Teil (`yt_proxy.py`, `claude-server`-Worktree) ist NICHT Teil dieses Plans — dieser Plan deckt ausschließlich die App-Seite ab und entwickelt/testet gegen einen gemockten Proxy (wie im gesamten `services/`-Verzeichnis schon üblich). ## Global Constraints - Build ohne `--dart-define`, keine Secrets im Code (bestehende Projektregel). - Kein Merge-Marker-Code, `flutter analyze` muss nach jedem Task sauber sein. - `flutter test --no-pub` muss nach jedem Task grün sein — bei 550+ Tests im Hintergrund laufen lassen (`run_in_background`), nicht mit kurzem Foreground-Timeout killen. - Server-Vertrag (aus der Spec, exakt so von der App erwartet): `POST /api/guest-token` → `200 {"guest_token": ""}`, kein Auth nötig. Anfragen mit Gast-Zugang senden `X-Guest-Token: ` statt `Authorization: Bearer …`. Erfolgsantwort von `POST /api/yt-dl` trägt bei Gast-Zugriff zusätzlich `"guest_remaining": `. - Neuer Branch für dieses Feature: `feature/youtube-gast-zugang` (Projektregel: `fix/xxx` oder `feature/xxx`) — vor Task 1 von `fix/p0-vollwertigkeit` abzweigen. - Vor dem Abschluss-Commit (Task 7): `/code-review` auf den vollen Diff, gefundene echte Probleme fixen (siehe Vorgehen in diesem Repo bisher). - CHANGELOG.md nach Abschluss aktualisieren (Pflicht laut CLAUDE.md). --- ## Task 1: `GastZugang`-Service **Files:** - Create: `lib/services/gast_zugang.dart` - Test: `test/services/gast_zugang_test.dart` **Interfaces:** - Consumes: `TokenSpeicher`, `SicherenSpeicher` aus `lib/services/baka_auth.dart` (bereits vorhanden, keine Änderung nötig). - Produces (für Task 2, 3, 5, 6): - `class GastZugang extends ChangeNotifier` - `GastZugang({http.Client? client, TokenSpeicher? speicher})` - `bool get hatToken` - `int? get verbleibend` - `Map get gastHeader` - `Future laden()` - `Future holeToken()` — `null` bei Erfolg, sonst Fehlertext - `void merkeVerbleibend(int n)` - [ ] **Step 0: Feature-Branch anlegen** ```bash git checkout -b feature/youtube-gast-zugang ``` Ab hier alle Commits dieses Plans auf diesem Branch, nicht auf `fix/p0-vollwertigkeit`. - [ ] **Step 1: Write the failing test** Create `test/services/gast_zugang_test.dart`: ```dart import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:melo/services/baka_auth.dart'; import 'package:melo/services/gast_zugang.dart'; class _MemorySpeicher implements TokenSpeicher { final Map werte = {}; @override Future lesen(String key) async => werte[key]; @override Future schreiben(String key, String wert) async => werte[key] = wert; @override Future loeschen(String key) async => werte.remove(key); } void main() { test('Ohne Token ist gastHeader leer', () { final gast = GastZugang( client: MockClient((_) async => http.Response('', 500)), speicher: _MemorySpeicher(), ); expect(gast.hatToken, isFalse); expect(gast.gastHeader, isEmpty); }); test('holeToken() speichert den Token bei Erfolg', () async { final speicher = _MemorySpeicher(); final gast = GastZugang( client: MockClient( (_) async => http.Response(jsonEncode({'guest_token': 'g-1'}), 200)), speicher: speicher, ); final fehler = await gast.holeToken(); expect(fehler, isNull); expect(gast.hatToken, isTrue); expect(gast.gastHeader, {'X-Guest-Token': 'g-1'}); expect(speicher.werte['guest_token'], 'g-1'); }); test('holeToken() meldet einen Fehler bei einer toten Verbindung', () async { final gast = GastZugang( client: MockClient((_) async => throw Exception('down')), speicher: _MemorySpeicher(), ); final fehler = await gast.holeToken(); expect(fehler, 'Proxy nicht erreichbar'); expect(gast.hatToken, isFalse); }); test('holeToken() meldet einen Fehler bei einem Server-Fehlerstatus', () async { final gast = GastZugang( client: MockClient((_) async => http.Response('', 500)), speicher: _MemorySpeicher(), ); final fehler = await gast.holeToken(); expect(fehler, 'Gast-Zugang fehlgeschlagen (500)'); expect(gast.hatToken, isFalse); }); test('holeToken() meldet einen Fehler bei unlesbarer Antwort', () async { final gast = GastZugang( client: MockClient((_) async => http.Response('nicht json', 200)), speicher: _MemorySpeicher(), ); final fehler = await gast.holeToken(); expect(fehler, 'Proxy antwortet unverständlich (200)'); expect(gast.hatToken, isFalse); }); test('holeToken() meldet einen Fehler ohne guest_token-Feld', () async { final gast = GastZugang( client: MockClient((_) async => http.Response(jsonEncode({}), 200)), speicher: _MemorySpeicher(), ); final fehler = await gast.holeToken(); expect(fehler, 'Proxy hat keinen Gast-Token geliefert'); expect(gast.hatToken, isFalse); }); test('laden() stellt einen gespeicherten Gast-Token wieder her', () async { final speicher = _MemorySpeicher(); speicher.werte['guest_token'] = 'g-alt'; final gast = GastZugang( client: MockClient((_) async => http.Response('', 500)), speicher: speicher, ); await gast.laden(); expect(gast.hatToken, isTrue); expect(gast.gastHeader, {'X-Guest-Token': 'g-alt'}); }); test('merkeVerbleibend übernimmt den zuletzt gemeldeten Stand', () { final gast = GastZugang( client: MockClient((_) async => http.Response('', 500)), speicher: _MemorySpeicher(), ); gast.merkeVerbleibend(3); expect(gast.verbleibend, 3); }); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `/home/dustin/development/flutter/bin/flutter test --no-pub test/services/gast_zugang_test.dart` Expected: FAIL — `Target of URI doesn't exist: 'package:melo/services/gast_zugang.dart'` (die Datei existiert noch nicht). - [ ] **Step 3: Write minimal implementation** Create `lib/services/gast_zugang.dart`: ```dart import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'baka_auth.dart' show TokenSpeicher, SicherenSpeicher; import 'yt_download_service.dart'; /// Anonymer Gast-Zugang zum YouTube-Proxy: kein Passwort, aber mit /// serverseitig durchgesetztem Tages-Limit (der Proxy zählt und meldet den /// Stand über `guest_remaining` in der yt-dl-Antwort zurück). Ergänzt /// [BakaAuth]/`baka_auth.dart`, ersetzt es nicht. class GastZugang extends ChangeNotifier { GastZugang({http.Client? client, TokenSpeicher? speicher}) : _client = client ?? http.Client(), _speicher = speicher ?? const SicherenSpeicher(); static const proxyUrl = YtDownloadService.proxyUrl; static const _tokenKey = 'guest_token'; final http.Client _client; final TokenSpeicher _speicher; String? _token; int? _verbleibend; bool get hatToken => _token != null && _token!.isNotEmpty; /// Zuletzt vom Server gemeldeter Kontingent-Stand — nur fürs Anzeigen, /// die Wahrheit bleibt beim Server. `null` vor dem ersten Download. int? get verbleibend => _verbleibend; /// Header für Anfragen an den Proxy. Ohne Token leer. Map get gastHeader => {if (hatToken) 'X-Guest-Token': _token!}; /// Stellt einen früher geholten Gast-Token wieder her (App-Start). Future laden() async { _token = await _speicher.lesen(_tokenKey); notifyListeners(); } /// Holt einen neuen Gast-Token vom Proxy. Gibt `null` bei Erfolg zurück, /// sonst den Fehlertext. Future holeToken() async { final http.Response antwort; try { antwort = await _client .post(Uri.parse('$proxyUrl/api/guest-token')) .timeout(const Duration(seconds: 15)); } catch (e) { debugPrint('Gast-Token nicht erreichbar: $e'); return 'Proxy nicht erreichbar'; } if (antwort.statusCode != 200) { return 'Gast-Zugang fehlgeschlagen (${antwort.statusCode})'; } Map daten; try { daten = jsonDecode(antwort.body) as Map; } catch (_) { return 'Proxy antwortet unverständlich (${antwort.statusCode})'; } final token = daten['guest_token'] as String?; if (token == null || token.isEmpty) { return 'Proxy hat keinen Gast-Token geliefert'; } _token = token; await _speicher.schreiben(_tokenKey, token); notifyListeners(); return null; } /// Übernimmt den zuletzt vom Server gemeldeten Kontingent-Stand. void merkeVerbleibend(int n) { _verbleibend = n; notifyListeners(); } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `/home/dustin/development/flutter/bin/flutter test --no-pub test/services/gast_zugang_test.dart` Expected: `00:0X +8: All tests passed!` - [ ] **Step 5: Run analyze** Run: `timeout 300 /home/dustin/development/flutter/bin/flutter analyze --no-pub` Expected: `No issues found!` - [ ] **Step 6: Commit** ```bash git add lib/services/gast_zugang.dart test/services/gast_zugang_test.dart git commit -m "$(cat <<'EOF' Neuer GastZugang-Service für den anonymen YouTube-Gast-Zugang Analog zu BakaAuth, aber ohne Passwort: holt/speichert einen geräte-gebundenen Gast-Token vom neuen /api/guest-token-Endpunkt (siehe docs/superpowers/specs/2026-08-26-youtube-guest-quota-design.md). Co-Authored-By: Claude Sonnet 5 EOF )" ``` --- ## Task 2: `YtDownloadService` um Gast-Zugang erweitern **Files:** - Modify: `lib/services/yt_download_service.dart` - Test: `test/services/yt_download_test.dart` **Interfaces:** - Consumes: `GastZugang` aus Task 1 (`hatToken`, `gastHeader`, `holeToken()`, `merkeVerbleibend(int)`). - Produces (für Task 4, 5, 6): `YtDownloadService({required BakaAuth auth, GastZugang? gast, http.Client? client})` — `gast` ist optional und rückwärtskompatibel (Default `null`, bestehendes Verhalten unverändert, wenn nicht gesetzt). - [ ] **Step 1: Write the failing tests** In `test/services/yt_download_test.dart`, Import ergänzen (nach der bestehenden `baka_auth.dart`-Zeile): ```dart import 'package:melo/services/baka_auth.dart'; import 'package:melo/services/gast_zugang.dart'; import 'package:melo/services/yt_download_service.dart'; ``` Am Ende der Datei, vor der letzten schließenden `}` von `main()`, folgende neue `group` einfügen: ```dart group('Gast-Zugang', () { test('Mit Gast-Token statt Anmeldung wird der Proxy trotzdem gefragt', () async { final speicher = _MemorySpeicher(); speicher.werte['guest_token'] = 'gast-xyz'; final gast = GastZugang( client: MockClient((_) async => http.Response('', 500)), speicher: speicher, ); await gast.laden(); final dienst = YtDownloadService( auth: BakaAuth( client: MockClient((_) async => http.Response('', 500)), speicher: _MemorySpeicher()), gast: gast, client: MockClient((req) async { if (req.url.path == '/api/yt-dl') { expect(req.headers['X-Guest-Token'], 'gast-xyz'); expect(req.headers.containsKey('Authorization'), isFalse); return http.Response( jsonEncode({ 'titel': 'Gast-Lied', 'dauer': 30, 'mp3_url': '/api/dl/g.mp3', 'guest_remaining': 4, }), 200); } return http.Response.bytes([1], 200); }), ); final ergebnis = await dienst.herunterladen('https://youtu.be/abc', zielOrdner: ziel.path); expect(ergebnis, isNotNull); expect(gast.verbleibend, 4); }); test('Ohne Anmeldung UND ohne Gast-Token wird gar nicht erst gefragt', () async { var aufrufe = 0; final dienst = YtDownloadService( auth: BakaAuth( client: MockClient((_) async => http.Response('', 500)), speicher: _MemorySpeicher()), client: MockClient((_) async { aufrufe++; return http.Response('', 200); }), ); final ergebnis = await dienst.herunterladen('https://youtu.be/abc', zielOrdner: ziel.path); expect(ergebnis, isNull); expect(dienst.fehler, 'Bitte zuerst beim Baka-Konto anmelden'); expect(aufrufe, 0); }); test('401 bei Gast-Zugang holt automatisch einen neuen Gast-Token', () async { var tokenAufrufe = 0; final speicher = _MemorySpeicher(); speicher.werte['guest_token'] = 'alter-token'; final gast = GastZugang( client: MockClient((_) async { tokenAufrufe++; return http.Response( jsonEncode({'guest_token': 'neuer-token'}), 200); }), speicher: speicher, ); await gast.laden(); final dienst = YtDownloadService( auth: BakaAuth( client: MockClient((_) async => http.Response('', 500)), speicher: _MemorySpeicher()), gast: gast, client: MockClient((_) async => http.Response('Unauthorized', 401)), ); final ergebnis = await dienst.herunterladen('https://youtu.be/abc', zielOrdner: ziel.path); // Das Token-Nachholen läuft unawaited im Hintergrund. await Future.delayed(Duration.zero); expect(ergebnis, isNull); expect(dienst.fehler, 'Gast-Zugang abgelaufen — bitte erneut versuchen'); expect(tokenAufrufe, 1); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `/home/dustin/development/flutter/bin/flutter test --no-pub test/services/yt_download_test.dart` Expected: FAIL — `The named parameter 'gast' isn't defined` (Compile-Fehler, `YtDownloadService` kennt `gast` noch nicht). - [ ] **Step 3: Write minimal implementation** In `lib/services/yt_download_service.dart`: Import ergänzen (nach `import 'baka_auth.dart';`): ```dart import 'baka_auth.dart'; import 'gast_zugang.dart'; ``` Konstruktor und Feld ändern (bisher `YtDownloadService({required this.auth, http.Client? client})` mit Feld `final BakaAuth auth;`): ```dart class YtDownloadService extends ChangeNotifier { YtDownloadService({required this.auth, this.gast, http.Client? client}) : _client = client ?? http.Client(); static const proxyUrl = 'https://yt.baka-net.de'; final BakaAuth auth; final GastZugang? gast; final http.Client _client; ``` Den Anfang von `herunterladen()` ersetzen — bisher: ```dart if (!auth.istAngemeldet) { _scheitere('Bitte zuerst beim Baka-Konto anmelden'); return null; } if (!url.contains('youtube.com') && !url.contains('youtu.be')) { _scheitere('Das ist keine YouTube-Adresse'); return null; } // ─── 1/2: Proxy fragen — er lädt das Video und wandelt es um ─── _melde('Proxy wird gefragt …'); final http.Response antwort; try { antwort = await _abbrechbar( _client .post( Uri.parse('$proxyUrl/api/yt-dl'), headers: { ...auth.authHeader, 'Content-Type': 'application/json' }, body: jsonEncode({'url': url, 'cookies': cookies}), ) .timeout(const Duration(seconds: 180)), abbruch, ); ``` neu: ```dart final gastAktiv = !auth.istAngemeldet && (gast?.hatToken ?? false); if (!auth.istAngemeldet && !gastAktiv) { _scheitere('Bitte zuerst beim Baka-Konto anmelden'); return null; } if (!url.contains('youtube.com') && !url.contains('youtu.be')) { _scheitere('Das ist keine YouTube-Adresse'); return null; } final zugangsHeader = gastAktiv ? gast!.gastHeader : auth.authHeader; // ─── 1/2: Proxy fragen — er lädt das Video und wandelt es um ─── _melde('Proxy wird gefragt …'); final http.Response antwort; try { antwort = await _abbrechbar( _client .post( Uri.parse('$proxyUrl/api/yt-dl'), headers: { ...zugangsHeader, 'Content-Type': 'application/json' }, body: jsonEncode({'url': url, 'cookies': cookies}), ) .timeout(const Duration(seconds: 180)), abbruch, ); ``` Die 401-Behandlung ersetzen — bisher: ```dart if (antwort.statusCode == 401) { _scheitere('Anmeldung abgelaufen — bitte neu anmelden'); return null; } ``` neu: ```dart if (antwort.statusCode == 401) { if (gastAktiv) unawaited(gast!.holeToken()); _scheitere(gastAktiv ? 'Gast-Zugang abgelaufen — bitte erneut versuchen' : 'Anmeldung abgelaufen — bitte neu anmelden'); return null; } ``` Nach dem Auslesen von `mp3Url` (bisher endet der Block mit `final mp3Url = daten['mp3_url'] as String? ?? '/api/dl/audio.mp3';` gefolgt von der Leerzeile vor `// ─── 2/2: ...`), zwei Zeilen einfügen: ```dart final mp3Url = daten['mp3_url'] as String? ?? '/api/dl/audio.mp3'; final gastRest = daten['guest_remaining'] as int?; if (gastAktiv && gastRest != null) { gast!.merkeVerbleibend(gastRest); } // ─── 2/2: fertige MP3 abholen und speichern ─── ``` Den MP3-Abruf-Header ersetzen — bisher: ```dart final mp3 = await _abbrechbar( _client .get(Uri.parse('$proxyUrl$mp3Url'), headers: auth.authHeader) .timeout(const Duration(seconds: 180)), abbruch, ); ``` neu: ```dart final mp3 = await _abbrechbar( _client .get(Uri.parse('$proxyUrl$mp3Url'), headers: zugangsHeader) .timeout(const Duration(seconds: 180)), abbruch, ); ``` - [ ] **Step 4: Run tests to verify they pass** Run: `/home/dustin/development/flutter/bin/flutter test --no-pub test/services/yt_download_test.dart` Expected: alle Tests grün (bestehende 15 + 3 neue = 18). - [ ] **Step 5: Run analyze** Run: `timeout 300 /home/dustin/development/flutter/bin/flutter analyze --no-pub` Expected: `No issues found!` - [ ] **Step 6: Commit** ```bash git add lib/services/yt_download_service.dart test/services/yt_download_test.dart git commit -m "$(cat <<'EOF' YtDownloadService: Gast-Zugang neben Baka-Auth unterstützen Optionaler gast-Parameter (GastZugang) — sendet bei fehlender Baka-Anmeldung X-Guest-Token statt Authorization, übernimmt guest_remaining aus der Erfolgsantwort, holt bei 401 automatisch einen neuen Gast-Token nach. Co-Authored-By: Claude Sonnet 5 EOF )" ``` --- ## Task 3: `YtSearchService` um Gast-Zugang erweitern **Files:** - Modify: `lib/services/yt_search_service.dart` - Test: `test/services/yt_search_service_test.dart` **Interfaces:** - Consumes: `GastZugang` aus Task 1 (gleiche Methoden wie Task 2). - Produces (für Task 4, 6): `YtSearchService({required BakaAuth auth, GastZugang? gast, http.Client? client})`. - [ ] **Step 1: Write the failing tests** In `test/services/yt_search_service_test.dart`, Import ergänzen: ```dart import 'package:melo/services/baka_auth.dart'; import 'package:melo/services/gast_zugang.dart'; import 'package:melo/services/yt_search_service.dart'; ``` Innerhalb von `group('YtSearchService.suchen', () { ... })`, vor dem schließenden `});` der Gruppe, zwei neue Tests einfügen: ```dart test('Mit Gast-Token statt Anmeldung wird trotzdem gesucht', () async { final speicher = _MemorySpeicher(); speicher.werte['guest_token'] = 'gast-xyz'; final gast = GastZugang( client: MockClient((_) async => http.Response('', 500)), speicher: speicher, ); await gast.laden(); final dienst = YtSearchService( auth: BakaAuth( client: MockClient((_) async => http.Response('', 500)), speicher: _MemorySpeicher()), gast: gast, client: MockClient((req) async { expect(req.headers['X-Guest-Token'], 'gast-xyz'); expect(req.headers.containsKey('Authorization'), isFalse); return http.Response('[]', 200); }), ); await dienst.suchen('roses'); expect(dienst.fehler, isNull); }); test('401 bei Gast-Zugang holt automatisch einen neuen Gast-Token', () async { var tokenAufrufe = 0; final speicher = _MemorySpeicher(); speicher.werte['guest_token'] = 'alter-token'; final gast = GastZugang( client: MockClient((_) async { tokenAufrufe++; return http.Response( jsonEncode({'guest_token': 'neuer-token'}), 200); }), speicher: speicher, ); await gast.laden(); final dienst = YtSearchService( auth: BakaAuth( client: MockClient((_) async => http.Response('', 500)), speicher: _MemorySpeicher()), gast: gast, client: MockClient((_) async => http.Response('Unauthorized', 401)), ); await dienst.suchen('roses'); await Future.delayed(Duration.zero); expect(dienst.fehler, 'Gast-Zugang abgelaufen — bitte erneut versuchen'); expect(tokenAufrufe, 1); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `/home/dustin/development/flutter/bin/flutter test --no-pub test/services/yt_search_service_test.dart` Expected: FAIL — `The named parameter 'gast' isn't defined`. - [ ] **Step 3: Write minimal implementation** In `lib/services/yt_search_service.dart`: Import ergänzen (nach `import 'baka_auth.dart';`): ```dart import 'baka_auth.dart'; import 'gast_zugang.dart'; import 'yt_download_service.dart'; ``` Konstruktor und Feld ändern — bisher `YtSearchService({required this.auth, http.Client? client})` mit `final BakaAuth auth;`: ```dart class YtSearchService extends ChangeNotifier { YtSearchService({required this.auth, this.gast, http.Client? client}) : _client = client ?? http.Client(); static const proxyUrl = YtDownloadService.proxyUrl; final BakaAuth auth; final GastZugang? gast; final http.Client _client; ``` `suchen()` anpassen — bisher: ```dart if (!auth.istAngemeldet) { _treffer = const []; _fehler = 'Bitte zuerst beim Baka-Konto anmelden'; notifyListeners(); return; } _laeuft = true; _fehler = null; notifyListeners(); final uri = Uri.parse('$proxyUrl/api/search') .replace(queryParameters: {'q': q}); final http.Response antwort; try { antwort = await _client .get(uri, headers: auth.authHeader) .timeout(const Duration(seconds: 20)); } on TimeoutException { _scheitere('Die Suche braucht zu lange — bitte später erneut versuchen'); return; } catch (e) { debugPrint('YT-Suche nicht erreichbar: $e'); _scheitere('Proxy nicht erreichbar'); return; } if (antwort.statusCode == 401) { _scheitere('Anmeldung abgelaufen — bitte neu anmelden'); return; } ``` neu: ```dart final gastAktiv = !auth.istAngemeldet && (gast?.hatToken ?? false); if (!auth.istAngemeldet && !gastAktiv) { _treffer = const []; _fehler = 'Bitte zuerst beim Baka-Konto anmelden'; notifyListeners(); return; } _laeuft = true; _fehler = null; notifyListeners(); final uri = Uri.parse('$proxyUrl/api/search') .replace(queryParameters: {'q': q}); final http.Response antwort; try { antwort = await _client .get(uri, headers: gastAktiv ? gast!.gastHeader : auth.authHeader) .timeout(const Duration(seconds: 20)); } on TimeoutException { _scheitere('Die Suche braucht zu lange — bitte später erneut versuchen'); return; } catch (e) { debugPrint('YT-Suche nicht erreichbar: $e'); _scheitere('Proxy nicht erreichbar'); return; } if (antwort.statusCode == 401) { if (gastAktiv) unawaited(gast!.holeToken()); _scheitere(gastAktiv ? 'Gast-Zugang abgelaufen — bitte erneut versuchen' : 'Anmeldung abgelaufen — bitte neu anmelden'); return; } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `/home/dustin/development/flutter/bin/flutter test --no-pub test/services/yt_search_service_test.dart` Expected: alle Tests grün (bestehende 11 + 2 neue = 13). - [ ] **Step 5: Run analyze** Run: `timeout 300 /home/dustin/development/flutter/bin/flutter analyze --no-pub` Expected: `No issues found!` - [ ] **Step 6: Commit** ```bash git add lib/services/yt_search_service.dart test/services/yt_search_service_test.dart git commit -m "$(cat <<'EOF' YtSearchService: Gast-Zugang neben Baka-Auth unterstützen Analog zu YtDownloadService: optionaler gast-Parameter, X-Guest- Token statt Authorization, automatisches Token-Nachholen bei 401. Co-Authored-By: Claude Sonnet 5 EOF )" ``` --- ## Task 4: `GastZugang` in `main.dart` verdrahten **Files:** - Modify: `lib/main.dart` **Interfaces:** - Consumes: `GastZugang` (Task 1), `YtDownloadService`/`YtSearchService` mit `gast`-Parameter (Task 2, 3). - Produces (für Task 5, 6): App-weiter `GastZugang`-Provider, erreichbar über `context.watch()`/`context.read()`. Kein eigener Test — reine Verdrahtung, wird durch die Widget-Tests in Task 5/6 (die `GastZugang` aus dem Provider-Baum lesen) sowie den bestehenden `home_shell_test.dart`/App-Start-Test indirekt abgedeckt. - [ ] **Step 1: Import ergänzen** In `lib/main.dart`, nach `import 'services/geteilte_adresse.dart';`: ```dart import 'services/geteilte_adresse.dart'; import 'services/gast_zugang.dart'; ``` - [ ] **Step 2: Late-Field ergänzen** Nach `late final BakaAuth _bakaAuth;`: ```dart late final BakaAuth _bakaAuth; late final GastZugang _gastZugang; ``` - [ ] **Step 3: Instanziierung + Verdrahtung in `main()`** Bisher: ```dart _bakaAuth = BakaAuth(); await _bakaAuth.laden(); _ytDownload = YtDownloadService(auth: _bakaAuth); _ytSearch = YtSearchService(auth: _bakaAuth); ``` neu: ```dart _bakaAuth = BakaAuth(); await _bakaAuth.laden(); _gastZugang = GastZugang(); await _gastZugang.laden(); _ytDownload = YtDownloadService(auth: _bakaAuth, gast: _gastZugang); _ytSearch = YtSearchService(auth: _bakaAuth, gast: _gastZugang); ``` - [ ] **Step 4: Provider ergänzen** In `MeloApp.build()`, nach `ChangeNotifierProvider.value(value: _bakaAuth),`: ```dart ChangeNotifierProvider.value(value: _bakaAuth), ChangeNotifierProvider.value(value: _gastZugang), ``` - [ ] **Step 5: Run analyze** Run: `timeout 300 /home/dustin/development/flutter/bin/flutter analyze --no-pub` Expected: `No issues found!` - [ ] **Step 6: Vollen Testlauf im Hintergrund starten** Run (im Hintergrund, siehe Global Constraints): `/home/dustin/development/flutter/bin/flutter test --no-pub` Expected: alle bisherigen Tests weiterhin grün — `main.dart` wird nicht direkt getestet, aber ein Compile-Fehler dort lässt JEDEN Widget-Test fehlschlagen, das ist der eigentliche Schutz hier. - [ ] **Step 7: Commit** ```bash git add lib/main.dart git commit -m "$(cat <<'EOF' main.dart: GastZugang app-weit verdrahten Neue Instanz wird beim Start geladen (gespeicherten Gast-Token wiederherstellen), an YtDownloadService/YtSearchService gereicht und als Provider bereitgestellt. Co-Authored-By: Claude Sonnet 5 EOF )" ``` --- ## Task 5: UI im Download-Tab (`downloads_screen.dart`) **Files:** - Modify: `lib/downloads/downloads_screen.dart` - Test: `test/downloads/online_screen_test.dart` **Interfaces:** - Consumes: `GastZugang` aus dem Provider-Baum (Task 4), `pruefeServerUser` (bereits vorhanden aus `baka_auth.dart`). - Produces: nichts, das andere Tasks konsumieren — UI-Endpunkt. - [ ] **Step 1: Write the failing tests** In `test/downloads/online_screen_test.dart`, Import ergänzen (nach `import 'package:melo/services/download_service.dart';`): ```dart import 'package:melo/services/download_service.dart'; import 'package:melo/services/gast_zugang.dart'; ``` `_wrap` ersetzen — bisher: ```dart Widget _wrap(BakaAuth auth, {required MeloDb db, required CategoryService categories, NavidromeService? navidrome}) { return MultiProvider( providers: [ Provider.value(value: db), // Bewusst .value: der Test besitzt den Dienst und schließt ihn im // tearDown. Würde ihn der Baum beim Abbauen schließen, bliebe ein // Aufräum-Timer von drift im Test-Rahmen hängen. ChangeNotifierProvider.value(value: categories), ChangeNotifierProvider.value(value: auth), ChangeNotifierProvider( create: (_) => YtDownloadService( auth: auth, client: MockClient((_) async => http.Response('', 500)), ), ), // Nur fürs Badge im Unterreiter-Kopf gebraucht (dienst.laeuft) — der // Download-Tab beobachtet ihn app-weit, unabhängig vom Unterreiter. ChangeNotifierProvider( create: (_) => DownloadService( db: db, navidrome: navidrome ?? NavidromeService()), ), ], child: const MaterialApp(home: Scaffold(body: DownloadsScreen())), ); } ``` neu: ```dart Widget _wrap(BakaAuth auth, {required MeloDb db, required CategoryService categories, NavidromeService? navidrome, GastZugang? gast}) { final gastZugang = gast ?? GastZugang(); return MultiProvider( providers: [ Provider.value(value: db), // Bewusst .value: der Test besitzt den Dienst und schließt ihn im // tearDown. Würde ihn der Baum beim Abbauen schließen, bliebe ein // Aufräum-Timer von drift im Test-Rahmen hängen. ChangeNotifierProvider.value(value: categories), ChangeNotifierProvider.value(value: auth), ChangeNotifierProvider.value(value: gastZugang), ChangeNotifierProvider( create: (_) => YtDownloadService( auth: auth, gast: gastZugang, client: MockClient((_) async => http.Response('', 500)), ), ), // Nur fürs Badge im Unterreiter-Kopf gebraucht (dienst.laeuft) — der // Download-Tab beobachtet ihn app-weit, unabhängig vom Unterreiter. ChangeNotifierProvider( create: (_) => DownloadService( db: db, navidrome: navidrome ?? NavidromeService()), ), ], child: const MaterialApp(home: Scaffold(body: DownloadsScreen())), ); } ``` Am Ende der Datei, vor dem letzten schließenden `}` von `main()`, drei neue Tests einfügen: ```dart testWidgets('Gast sieht zwei Buttons: Anmelden und Als Gast fortfahren', (tester) async { final auth = BakaAuth( client: MockClient((_) async => http.Response('', 500)), speicher: _MemorySpeicher()); await tester.pumpWidget(_wrap(auth, db: db, categories: categories)); await tester.pumpAndSettle(); expect(find.text('Beim Baka-Konto anmelden'), findsOneWidget); expect( find.text('Als Gast fortfahren (5 Downloads/Tag)'), findsOneWidget); expect(find.text('YouTube-Adresse'), findsNothing); }); testWidgets( 'Als Gast fortfahren holt einen Token und schaltet das Adressfeld frei', (tester) async { final auth = BakaAuth( client: MockClient((_) async => http.Response('', 500)), speicher: _MemorySpeicher()); final gast = GastZugang( client: MockClient((_) async => http.Response(jsonEncode({'guest_token': 'g-1'}), 200)), speicher: _MemorySpeicher(), ); await tester.pumpWidget( _wrap(auth, db: db, categories: categories, gast: gast)); await tester.pumpAndSettle(); await tester.tap(find.text('Als Gast fortfahren (5 Downloads/Tag)')); await tester.pumpAndSettle(); expect(find.text('YouTube-Adresse'), findsOneWidget); expect(find.text('Als Gast unterwegs (5 Downloads/Tag)'), findsOneWidget); expect(find.text('YouTube-Cookies des Servers verwenden'), findsNothing); }); testWidgets('Fehler beim Gast-Token wird angezeigt', (tester) async { final auth = BakaAuth( client: MockClient((_) async => http.Response('', 500)), speicher: _MemorySpeicher()); final gast = GastZugang( client: MockClient((_) async => http.Response('', 500)), speicher: _MemorySpeicher(), ); await tester.pumpWidget( _wrap(auth, db: db, categories: categories, gast: gast)); await tester.pumpAndSettle(); await tester.tap(find.text('Als Gast fortfahren (5 Downloads/Tag)')); await tester.pumpAndSettle(); expect(find.text('Gast-Zugang fehlgeschlagen (500)'), findsOneWidget); expect(find.text('YouTube-Adresse'), findsNothing); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `/home/dustin/development/flutter/bin/flutter test --no-pub test/downloads/online_screen_test.dart` Expected: FAIL — entweder Compile-Fehler (`gast` unbekannter Parameter, falls Step 3 hier vor der UI-Änderung läuft) oder `findsOneWidget` schlägt fehl, weil der neue Button/Zähler-Text noch nicht existiert. - [ ] **Step 3: Write minimal implementation** In `lib/downloads/downloads_screen.dart`, Import ergänzen (nach `import '../services/download_service.dart';`): ```dart import '../services/download_service.dart'; import '../services/gast_zugang.dart'; ``` In `_YouTubeBereichState`, neues Feld nach `bool _autoLoginFehlgeschlagen = false;`: ```dart /// Die automatische Anmeldung ist fehlgeschlagen (z. B. abweichendes /// Baka-Passwort) — dann bleibt der manuelle Anmelde-Weg als Rückfalloption /// sichtbar, sonst käme so ein User nie mehr an den Dialog heran. bool _autoLoginFehlgeschlagen = false; /// Fehlertext, falls das Holen des Gast-Tokens scheitert. String? _gastFehler; ``` Neue Methode nach `_anmelden()`: ```dart Future _anmelden() async { await showDialog( context: context, builder: (_) => const _AnmeldeDialog(), ); } Future _alsGastFortfahren() async { final gast = context.read(); setState(() => _gastFehler = null); final fehler = await gast.holeToken(); if (!mounted) return; if (fehler != null) setState(() => _gastFehler = fehler); } ``` `build()` komplett ersetzen — bisher: ```dart @override Widget build(BuildContext context) { final auth = context.watch(); final dienst = context.watch(); // Server-User gelten sofort als berechtigt — die Anmeldung passiert für // sie im Hintergrund (_pruefeServerUser). Nur wenn die automatische // Anmeldung nachweislich fehlgeschlagen ist, bleibt der manuelle Weg // als Rückfalloption sichtbar — sonst gäbe es keinen Weg mehr zurück. final zugriffOk = auth.istAngemeldet || (_serverUser && !_autoLoginFehlgeschlagen); return ListView( padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), children: [ if (!zugriffOk) ...[ Text( _autoLoginFehlgeschlagen ? 'Die automatische Anmeldung hat nicht geklappt — vermutlich ' 'weicht dein Server-Passwort vom Baka-Konto ab. Einmal ' 'manuell mit dem aktuellen Baka-Passwort anmelden, dann ' 'gleicht sich das Passwort wieder ab.' : 'Der Downloader läuft über den Baka-Server. Dafür brauchst ' 'du deine Baka-Anmeldung.', style: const TextStyle(color: MeloTheme.text2, fontSize: 13), ), const SizedBox(height: 12), SizedBox( width: double.infinity, height: MeloTheme.minTouchTarget + 4, child: FilledButton.icon( icon: const Icon(Icons.login), label: const Text('Beim Baka-Konto anmelden', style: TextStyle(fontSize: 16)), onPressed: _anmelden, ), ), ] else ...[ if (!_serverUser) ...[ Row( children: [ const Icon(Icons.verified_user, size: 18, color: MeloTheme.text3), const SizedBox(width: 8), Expanded( child: Text('Angemeldet als ${auth.benutzer}', style: const TextStyle(color: MeloTheme.text2, fontSize: 13)), ), TextButton( onPressed: auth.abmelden, child: const Text('Abmelden'), ), ], ), const SizedBox(height: 8), ], TextField( controller: _url, enabled: !dienst.laeuft, decoration: const InputDecoration( labelText: 'YouTube-Adresse', hintText: 'https://youtu.be/…', border: OutlineInputBorder(), ), ), const SizedBox(height: 12), _KategorieFeld(controller: _kategorie, aktiv: !dienst.laeuft), SwitchListTile( dense: true, contentPadding: EdgeInsets.zero, title: const Text('YouTube-Cookies des Servers verwenden'), subtitle: const Text('Nötig für altersbeschränkte Videos'), value: _cookies, onChanged: dienst.laeuft ? null : _setzeCookies, ), if (_orte.length > 1) ...[ ``` neu (der restliche Teil ab `if (_orte.length > 1) ...[` bis zum Ende der Methode bleibt unverändert stehen): ```dart @override Widget build(BuildContext context) { final auth = context.watch(); final gast = context.watch(); final dienst = context.watch(); // Server-User gelten sofort als berechtigt — die Anmeldung passiert für // sie im Hintergrund (_pruefeServerUser). Nur wenn die automatische // Anmeldung nachweislich fehlgeschlagen ist, bleibt der manuelle Weg // als Rückfalloption sichtbar — sonst gäbe es keinen Weg mehr zurück. // Ein Gast-Token zählt ebenfalls als Zugang, nur mit Tages-Limit. final zugriffOk = auth.istAngemeldet || (_serverUser && !_autoLoginFehlgeschlagen) || gast.hatToken; final istGastModus = !_serverUser && !auth.istAngemeldet && gast.hatToken; return ListView( padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), children: [ if (!zugriffOk) ...[ Text( _autoLoginFehlgeschlagen ? 'Die automatische Anmeldung hat nicht geklappt — vermutlich ' 'weicht dein Server-Passwort vom Baka-Konto ab. Einmal ' 'manuell mit dem aktuellen Baka-Passwort anmelden, dann ' 'gleicht sich das Passwort wieder ab.' : 'Der Downloader läuft über den Baka-Server. Dafür brauchst ' 'du deine Baka-Anmeldung.', style: const TextStyle(color: MeloTheme.text2, fontSize: 13), ), const SizedBox(height: 12), SizedBox( width: double.infinity, height: MeloTheme.minTouchTarget + 4, child: FilledButton.icon( icon: const Icon(Icons.login), label: const Text('Beim Baka-Konto anmelden', style: TextStyle(fontSize: 16)), onPressed: _anmelden, ), ), if (!_serverUser) ...[ const SizedBox(height: 8), SizedBox( width: double.infinity, height: MeloTheme.minTouchTarget + 4, child: OutlinedButton.icon( icon: const Icon(Icons.person_outline), label: const Text('Als Gast fortfahren (5 Downloads/Tag)', style: TextStyle(fontSize: 16)), onPressed: _alsGastFortfahren, ), ), if (_gastFehler != null) ...[ const SizedBox(height: 8), Text(_gastFehler!, style: const TextStyle(color: MeloTheme.red, fontSize: 13)), ], ], ] else ...[ if (!_serverUser && auth.istAngemeldet) ...[ Row( children: [ const Icon(Icons.verified_user, size: 18, color: MeloTheme.text3), const SizedBox(width: 8), Expanded( child: Text('Angemeldet als ${auth.benutzer}', style: const TextStyle(color: MeloTheme.text2, fontSize: 13)), ), TextButton( onPressed: auth.abmelden, child: const Text('Abmelden'), ), ], ), const SizedBox(height: 8), ], if (istGastModus) ...[ Row( children: [ const Icon(Icons.person_outline, size: 18, color: MeloTheme.text3), const SizedBox(width: 8), Expanded( child: Text( gast.verbleibend == null ? 'Als Gast unterwegs (5 Downloads/Tag)' : 'Als Gast unterwegs — noch ${gast.verbleibend} von 5 heute', style: const TextStyle(color: MeloTheme.text2, fontSize: 13), ), ), ], ), const SizedBox(height: 8), ], TextField( controller: _url, enabled: !dienst.laeuft, decoration: const InputDecoration( labelText: 'YouTube-Adresse', hintText: 'https://youtu.be/…', border: OutlineInputBorder(), ), ), const SizedBox(height: 12), _KategorieFeld(controller: _kategorie, aktiv: !dienst.laeuft), if (!istGastModus) SwitchListTile( dense: true, contentPadding: EdgeInsets.zero, title: const Text('YouTube-Cookies des Servers verwenden'), subtitle: const Text('Nötig für altersbeschränkte Videos'), value: _cookies, onChanged: dienst.laeuft ? null : _setzeCookies, ), if (_orte.length > 1) ...[ ``` **Wichtig:** Nur der gezeigte Anfang von `build()` ändert sich (bis einschließlich des `SwitchListTile`/`if (!istGastModus)`-Blocks). Der Rest der Methode (`if (_orte.length > 1) ...[` bis zum schließenden `}` von `build()`, inklusive `FilledButton.icon` „Herunterladen“ und dem `dienst.laeuft`-Fortschrittsblock) bleibt exakt wie vorher stehen — insbesondere endet der `else`-Zweig weiterhin mit demselben schließenden `],` wie zuvor. - [ ] **Step 4: Run tests to verify they pass** Run: `/home/dustin/development/flutter/bin/flutter test --no-pub test/downloads/online_screen_test.dart` Expected: alle Tests grün (bestehende 12 + 3 neue = 15). - [ ] **Step 5: Run analyze** Run: `timeout 300 /home/dustin/development/flutter/bin/flutter analyze --no-pub` Expected: `No issues found!` - [ ] **Step 6: Commit** ```bash git add lib/downloads/downloads_screen.dart test/downloads/online_screen_test.dart git commit -m "$(cat <<'EOF' Download-Tab: Gast-Zugang mit bewusster Wahl + Tages-Zähler Echte Gäste sehen jetzt zwei Buttons: "Beim Baka-Konto anmelden" und "Als Gast fortfahren (5 Downloads/Tag)". Im Gast-Modus wird der Cookie-Schalter ausgeblendet (Server erzwingt cookies=false für Gast-Zugriffe ohnehin) und ein Zähler "noch N von 5 heute" gezeigt, sobald der Server ihn meldet. Co-Authored-By: Claude Sonnet 5 EOF )" ``` --- ## Task 6: UI im YT-Suche-Tab (`youtube_search_screen.dart`) **Files:** - Modify: `lib/downloads/youtube_search_screen.dart` - Test: `test/downloads/youtube_search_screen_test.dart` **Interfaces:** - Consumes: `GastZugang` aus dem Provider-Baum (Task 4). - Produces: nichts, das andere Tasks konsumieren — UI-Endpunkt. - [ ] **Step 1: Write the failing tests** In `test/downloads/youtube_search_screen_test.dart`, Import ergänzen: ```dart import 'package:melo/services/download_service.dart'; import 'package:melo/services/gast_zugang.dart'; import 'package:melo/services/navidrome_service.dart'; ``` `_wrap` ersetzen — bisher: ```dart Widget _wrap({ required MeloDb db, required BakaAuth auth, required YtSearchService search, required YtDownloadService download, NavidromeService? navidrome, }) { return MultiProvider( providers: [ Provider.value(value: db), ChangeNotifierProvider.value(value: LibraryService(db)), ChangeNotifierProvider.value(value: auth), ChangeNotifierProvider.value(value: search), ChangeNotifierProvider.value(value: download), // Nur für die Server-User-Prüfung gebraucht (siehe // _pruefeServerUser) — ohne gespeicherte Zugangsdaten bleibt sie ein // Gast. ChangeNotifierProvider( create: (_) => DownloadService( db: db, navidrome: navidrome ?? NavidromeService()), ), ], child: const MaterialApp(home: Scaffold(body: YoutubeSearchScreen())), ); } ``` neu: ```dart Widget _wrap({ required MeloDb db, required BakaAuth auth, required YtSearchService search, required YtDownloadService download, NavidromeService? navidrome, GastZugang? gast, }) { return MultiProvider( providers: [ Provider.value(value: db), ChangeNotifierProvider.value(value: LibraryService(db)), ChangeNotifierProvider.value(value: auth), ChangeNotifierProvider.value(value: gast ?? GastZugang()), ChangeNotifierProvider.value(value: search), ChangeNotifierProvider.value(value: download), // Nur für die Server-User-Prüfung gebraucht (siehe // _pruefeServerUser) — ohne gespeicherte Zugangsdaten bleibt sie ein // Gast. ChangeNotifierProvider( create: (_) => DownloadService( db: db, navidrome: navidrome ?? NavidromeService()), ), ], child: const MaterialApp(home: Scaffold(body: YoutubeSearchScreen())), ); } ``` Am Ende der Datei, vor dem letzten schließenden `}` von `main()`, zwei neue Tests einfügen: ```dart testWidgets('Gast sieht den Button "Als Gast fortfahren"', (tester) async { final db = MeloDb(NativeDatabase.memory()); final auth = BakaAuth( client: MockClient((_) async => http.Response('', 500)), speicher: _MemorySpeicher()); final search = YtSearchService(auth: auth); final download = YtDownloadService(auth: auth); await tester.pumpWidget(_wrap( db: db, auth: auth, search: search, download: download)); await tester.pumpAndSettle(); expect( find.text('Als Gast fortfahren (5 Downloads/Tag)'), findsOneWidget); await db.close(); }); testWidgets( 'Als Gast fortfahren blendet den Baka-Hinweis aus und zeigt den Zähler', (tester) async { final db = MeloDb(NativeDatabase.memory()); final auth = BakaAuth( client: MockClient((_) async => http.Response('', 500)), speicher: _MemorySpeicher()); final search = YtSearchService(auth: auth); final download = YtDownloadService(auth: auth); final gast = GastZugang( client: MockClient((_) async => http.Response(jsonEncode({'guest_token': 'g-1'}), 200)), speicher: _MemorySpeicher(), ); await tester.pumpWidget(_wrap( db: db, auth: auth, search: search, download: download, gast: gast)); await tester.pumpAndSettle(); await tester.tap(find.text('Als Gast fortfahren (5 Downloads/Tag)')); await tester.pumpAndSettle(); expect(find.textContaining('Baka-Anmeldung'), findsNothing); expect(find.text('Als Gast unterwegs (5 Downloads/Tag)'), findsOneWidget); await db.close(); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `/home/dustin/development/flutter/bin/flutter test --no-pub test/downloads/youtube_search_screen_test.dart` Expected: FAIL — `findsOneWidget` schlägt fehl, der neue Button existiert noch nicht. - [ ] **Step 3: Write minimal implementation** In `lib/downloads/youtube_search_screen.dart`, Import ergänzen (nach `import '../services/download_service.dart';`): ```dart import '../services/download_service.dart'; import '../services/gast_zugang.dart'; ``` Neues Feld + neue Methode in `_YoutubeSearchScreenState`, nach `bool _autoLoginFehlgeschlagen = false;`: ```dart /// Die automatische Anmeldung ist fehlgeschlagen — dann bleibt der /// Hinweis auf die manuelle Anmeldung (siehe Download-Tab) sichtbar. bool _autoLoginFehlgeschlagen = false; /// Fehlertext, falls das Holen des Gast-Tokens scheitert. String? _gastFehler; ``` Nach der `_pruefeServerUser()`-Methode, vor `dispose()`: ```dart Future _alsGastFortfahren() async { final gast = context.read(); setState(() => _gastFehler = null); final fehler = await gast.holeToken(); if (!mounted) return; if (fehler != null) setState(() => _gastFehler = fehler); } ``` `build()` ersetzen — bisher: ```dart @override Widget build(BuildContext context) { final auth = context.watch(); final suche = context.watch(); final zugriffOk = auth.istAngemeldet || (_serverUser && !_autoLoginFehlgeschlagen); return SafeArea( child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Row( children: [ Expanded( child: TextField( controller: _query, textInputAction: TextInputAction.search, onSubmitted: (_) => _suchen(), decoration: const InputDecoration( hintText: 'YouTube durchsuchen …', prefixIcon: Icon(Icons.search), ), ), ), const SizedBox(width: 8), FilledButton( onPressed: _suchen, child: const Text('Suchen'), ), ], ), ), if (!zugriffOk) const Padding( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Text( 'Die Suche läuft über den Baka-Server. Dafür brauchst du ' 'deine Baka-Anmeldung (siehe Download-Tab).', style: TextStyle(color: MeloTheme.text2, fontSize: 13), ), ), if (suche.laeuft) const LinearProgressIndicator(), ``` neu (der Rest der Methode ab `if (suche.laeuft) const LinearProgressIndicator(),` bleibt unverändert): ```dart @override Widget build(BuildContext context) { final auth = context.watch(); final gast = context.watch(); final suche = context.watch(); final zugriffOk = auth.istAngemeldet || (_serverUser && !_autoLoginFehlgeschlagen) || gast.hatToken; final istGastModus = !_serverUser && !auth.istAngemeldet && gast.hatToken; return SafeArea( child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Row( children: [ Expanded( child: TextField( controller: _query, textInputAction: TextInputAction.search, onSubmitted: (_) => _suchen(), decoration: const InputDecoration( hintText: 'YouTube durchsuchen …', prefixIcon: Icon(Icons.search), ), ), ), const SizedBox(width: 8), FilledButton( onPressed: _suchen, child: const Text('Suchen'), ), ], ), ), if (!zugriffOk) ...[ Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Text( 'Die Suche läuft über den Baka-Server. Dafür brauchst du ' 'deine Baka-Anmeldung (siehe Download-Tab).', style: const TextStyle(color: MeloTheme.text2, fontSize: 13), ), ), if (!_serverUser) ...[ Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: SizedBox( width: double.infinity, height: MeloTheme.minTouchTarget + 4, child: OutlinedButton.icon( icon: const Icon(Icons.person_outline), label: const Text('Als Gast fortfahren (5 Downloads/Tag)', style: TextStyle(fontSize: 16)), onPressed: _alsGastFortfahren, ), ), ), if (_gastFehler != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Text(_gastFehler!, style: const TextStyle(color: MeloTheme.red, fontSize: 13)), ), ], ], if (istGastModus) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Text( gast.verbleibend == null ? 'Als Gast unterwegs (5 Downloads/Tag)' : 'Als Gast unterwegs — noch ${gast.verbleibend} von 5 heute', style: const TextStyle(color: MeloTheme.text2, fontSize: 13), ), ), if (suche.laeuft) const LinearProgressIndicator(), ``` - [ ] **Step 4: Run tests to verify they pass** Run: `/home/dustin/development/flutter/bin/flutter test --no-pub test/downloads/youtube_search_screen_test.dart` Expected: alle Tests grün (bestehende 4 + 2 neue = 6). - [ ] **Step 5: Run analyze** Run: `timeout 300 /home/dustin/development/flutter/bin/flutter analyze --no-pub` Expected: `No issues found!` - [ ] **Step 6: Commit** ```bash git add lib/downloads/youtube_search_screen.dart test/downloads/youtube_search_screen_test.dart git commit -m "$(cat <<'EOF' YT-Suche-Tab: Gast-Zugang mit bewusster Wahl + Tages-Zähler Analog zum Download-Tab: echte Gäste sehen zusätzlich zum bestehenden Hinweis den Button "Als Gast fortfahren (5 Downloads/Tag)"; im Gast-Modus ersetzt ein Zähler den Hinweis. Co-Authored-By: Claude Sonnet 5 EOF )" ``` --- ## Task 7: Gesamt-Verifikation, Code-Review, CHANGELOG **Files:** - Modify: `CHANGELOG.md` - [ ] **Step 1: Vollen Testlauf im Hintergrund** Run: `/home/dustin/development/flutter/bin/flutter test --no-pub` (im Hintergrund, siehe Global Constraints) Expected: alle Tests grün, Gesamtzahl = vorherige Gesamtzahl + 8 (Task 1) + 3 (Task 2) + 2 (Task 3) + 3 (Task 5) + 2 (Task 6) = +18 gegenüber dem Stand vor diesem Plan. - [ ] **Step 2: Analyze** Run: `timeout 300 /home/dustin/development/flutter/bin/flutter analyze --no-pub` Expected: `No issues found!` - [ ] **Step 3: Code-Review** `/code-review` auf den vollen Diff seit dem Abzweigen von `fix/p0-vollwertigkeit` laufen lassen. Echte, bestätigte Funde beheben (mit eigenem kleinen Commit je Fund, wie in diesem Repo üblich), triviale/ falsch-positive Funde begründet verwerfen. - [ ] **Step 4: CHANGELOG.md aktualisieren** Neuen Abschnitt ganz oben unter `## [Unreleased]` einfügen (Format wie die bestehenden Einträge: `### ()`, Stichpunkte mit ✅/🔧/🐛), der beschreibt: - Gast-Zugang mit bewusster Wahl in Download-Tab und YT-Suche-Tab - 5 Downloads/Tag-Limit (serverseitig durchgesetzt, App zeigt nur den vom Server gemeldeten Stand an) - Cookie-Schalter für Gäste ausgeblendet - Verweis auf die Spec (`docs/superpowers/specs/2026-08-26-youtube-guest-quota-design.md`) und den Hinweis, dass der Server-Teil (`yt_proxy.py`) separat läuft und vor dem echten Test auf dem Gerät fertig sein muss - Test-Gesamtzahl (aus Step 1) - [ ] **Step 5: Commit** ```bash git add CHANGELOG.md git commit -m "$(cat <<'EOF' CHANGELOG: YouTube-Gast-Zugang (App-Seite) Co-Authored-By: Claude Sonnet 5 EOF )" ``` - [ ] **Step 6: Push** ```bash git push origin feature/youtube-gast-zugang ``` **Hinweis für den Ausführenden:** Die App-Seite ist damit fertig und getestet, funktioniert aber gegen den echten Proxy erst, sobald `POST /api/guest-token` und die erweiterte `_checke_auth()`-Logik in `yt_proxy.py` existieren (siehe Spec, Abschnitt „Server-seitige Komponenten“) — das macht die `claude-server`-Session/Hermes. Vor einem echten APK-Test auf dem Gerät das mit Dustin abstimmen.