fix: Gast-Zugang-Sperre, veralteter Token, Doppel-Tap, Hinweistext (Branch-Review)

Behebt die Findings aus dem abschließenden Gesamt-Branch-Review für
feature/youtube-gast-zugang, alle in einem Rutsch:

- KRITISCH: Gast-Modus war eine Einbahnstraße — einmal "Als Gast
  fortfahren" getippt, war _AnmeldeDialog (einziger Baka-Login-Einstieg,
  auch für Melo-Cloud-Sync) dauerhaft unerreichbar. Neu:
  GastZugang.verwerfen() plus "Anmelden"-Knopf neben der Gast-Statuszeile
  in beiden Screens.
- WICHTIG: zugriffOk prüfte den rohen gast.hatToken statt istGastModus —
  ein alter Gast-Token konnte den Auto-Login-Rückfallweg für Server-User
  verdecken. istGastModus wird jetzt vor zugriffOk berechnet und dort
  verwendet.
- WICHTIG: CHANGELOG ergänzt — der Server-Endpunkt POST /api/guest-token
  fehlt noch (separates Vorhaben).
- Doppel-Tap-Schutz für den Gast-Button in beiden Screens.
- Widersprüchlicher Hinweistext im YT-Suche-Tab (verlangte Baka-Login
  direkt über dem Gast-Button) korrigiert.
- Zwei Gast-Tests in youtube_search_screen_test.dart bekommen jetzt
  echtes gast:-Wiring in YtSearchService/YtDownloadService.
- 2 neue Regressionstests fürs Anmelden-Escape-Hatch.

Getestet: volle Suite grün (602 Tests, 1 skipped, 0 failed),
flutter analyze ohne Befund.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcDiyJdVRqh1TtJk5JiabX
This commit is contained in:
Hermes (Server)
2026-08-26 22:17:03 +02:00
co-authored by Claude Sonnet 5
parent b32f3198f8
commit d20f01adee
6 changed files with 175 additions and 23 deletions
+39
View File
@@ -5,8 +5,47 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased] ## [Unreleased]
### 🐛 Gast-Zugang: Fixe aus dem Branch-Abschluss-Review (2026-08-26)
- 🔓 **Gast-Modus war eine Einbahnstraße.** Einmal "Als Gast fortfahren"
angetippt, blieb `gast.hatToken` dauerhaft `true` (Token steckt im
Secure Storage, wird bei jedem App-Start wiederhergestellt) — und damit
war `_AnmeldeDialog`, der EINZIGE Einstieg in der App für die
Baka-Anmeldung (auch für den Melo-Cloud-Sync-Login!), unerreichbar.
Neu: `GastZugang.verwerfen()` verwirft den Token wieder, und ein
"Anmelden"-Knopf neben der Gast-Statuszeile (Download-Tab und
YT-Suche-Tab) ruft ihn auf.
- 🔀 **Ein alter Gast-Token hat den Auto-Login-Rückfallweg für
Server-User verdeckt.** `zugriffOk` prüfte bisher den rohen
`gast.hatToken` statt `istGastModus` — ein Gerät, das früher mal als
Gast lief und jetzt als Server-User mit fehlgeschlagenem Auto-Login
dasteht, bekam die Fehlermeldung dazu nie zu sehen und lud still über
den (limitierten) Gast-Pfad. `istGastModus` wird jetzt vor `zugriffOk`
berechnet und dort statt des rohen Tokens verwendet.
(`lib/downloads/downloads_screen.dart`,
`lib/downloads/youtube_search_screen.dart`)
- ⏱️ **Doppel-Tap auf "Als Gast fortfahren" konnte zwei Anfragen
gleichzeitig auslösen.** Beide Screens sperren den Button jetzt während
`holeToken()` läuft (`_gastLaeuft`).
- 📝 **Widersprüchlicher Hinweistext im YT-Suche-Tab behoben** — der Text
über dem Gast-Button verlangte fälschlich eine Baka-Anmeldung.
- ⚠️ **CHANGELOG ergänzt:** der Abschnitt zum Gast-Zugang unten macht jetzt
klar, dass die Server-Seite (`POST /api/guest-token`) noch fehlt.
-**Getestet:** 2 neue Regressionstests fürs "Anmelden"-Escape-Hatch
(`test/downloads/online_screen_test.dart`,
`test/downloads/youtube_search_screen_test.dart`), 2 bestehende Gast-Tests
im YT-Suche-Tab jetzt mit echtem `gast:`-Wiring in
`YtSearchService`/`YtDownloadService`. Volle Suite grün, `flutter
analyze` ohne Befund.
### 📺👤 YouTube-Gast-Zugang: bewusste Wahl + Tages-Zähler (Download-Tab + YT-Suche-Tab) (2026-08-26) ### 📺👤 YouTube-Gast-Zugang: bewusste Wahl + Tages-Zähler (Download-Tab + YT-Suche-Tab) (2026-08-26)
- ⚠️ **Braucht noch die Server-Seite.** Der neue Endpunkt `POST
/api/guest-token` und die Quota-Logik in `yt_proxy.py` existieren noch
nicht (separates Vorhaben, siehe
`docs/superpowers/specs/2026-08-26-youtube-guest-quota-design.md`). Bis
die Server-Seite steht, liefert der "Als Gast fortfahren"-Button
zuverlässig einen 404-Fehler.
- 👤 **Echte Gäste (kein Baka-Konto, kein Melo-Server) sehen jetzt zwei - 👤 **Echte Gäste (kein Baka-Konto, kein Melo-Server) sehen jetzt zwei
Buttons** statt nur der Anmeldung: "Beim Baka-Konto anmelden" und neu Buttons** statt nur der Anmeldung: "Beim Baka-Konto anmelden" und neu
"Als Gast fortfahren (5 Downloads/Tag)". Der Gast-Button holt sich per "Als Gast fortfahren (5 Downloads/Tag)". Der Gast-Button holt sich per
+19 -5
View File
@@ -619,6 +619,9 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
/// Fehlertext, falls das Holen des Gast-Tokens scheitert. /// Fehlertext, falls das Holen des Gast-Tokens scheitert.
String? _gastFehler; String? _gastFehler;
/// Verhindert Mehrfach-Anfragen bei schnellem Doppel-Antippen.
bool _gastLaeuft = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -690,11 +693,18 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
} }
Future<void> _alsGastFortfahren() async { Future<void> _alsGastFortfahren() async {
if (_gastLaeuft) return;
final gast = context.read<GastZugang>(); final gast = context.read<GastZugang>();
setState(() => _gastFehler = null); setState(() {
_gastFehler = null;
_gastLaeuft = true;
});
final fehler = await gast.holeToken(); final fehler = await gast.holeToken();
if (!mounted) return; if (!mounted) return;
if (fehler != null) setState(() => _gastFehler = fehler); setState(() {
_gastLaeuft = false;
if (fehler != null) _gastFehler = fehler;
});
} }
Future<void> _herunterladen() async { Future<void> _herunterladen() async {
@@ -755,10 +765,10 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
// Anmeldung nachweislich fehlgeschlagen ist, bleibt der manuelle Weg // Anmeldung nachweislich fehlgeschlagen ist, bleibt der manuelle Weg
// als Rückfalloption sichtbar — sonst gäbe es keinen Weg mehr zurück. // 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. // Ein Gast-Token zählt ebenfalls als Zugang, nur mit Tages-Limit.
final istGastModus = !_serverUser && !auth.istAngemeldet && gast.hatToken;
final zugriffOk = auth.istAngemeldet || final zugriffOk = auth.istAngemeldet ||
(_serverUser && !_autoLoginFehlgeschlagen) || (_serverUser && !_autoLoginFehlgeschlagen) ||
gast.hatToken; istGastModus;
final istGastModus = !_serverUser && !auth.istAngemeldet && gast.hatToken;
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
@@ -794,7 +804,7 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
icon: const Icon(Icons.person_outline), icon: const Icon(Icons.person_outline),
label: const Text('Als Gast fortfahren (5 Downloads/Tag)', label: const Text('Als Gast fortfahren (5 Downloads/Tag)',
style: TextStyle(fontSize: 16)), style: TextStyle(fontSize: 16)),
onPressed: _alsGastFortfahren, onPressed: _gastLaeuft ? null : _alsGastFortfahren,
), ),
), ),
if (_gastFehler != null) ...[ if (_gastFehler != null) ...[
@@ -834,6 +844,10 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
style: const TextStyle(color: MeloTheme.text2, fontSize: 13), style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
), ),
), ),
TextButton(
onPressed: gast.verwerfen,
child: const Text('Anmelden'),
),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
+31 -7
View File
@@ -38,6 +38,9 @@ class _YoutubeSearchScreenState extends State<YoutubeSearchScreen> {
/// Fehlertext, falls das Holen des Gast-Tokens scheitert. /// Fehlertext, falls das Holen des Gast-Tokens scheitert.
String? _gastFehler; String? _gastFehler;
/// Verhindert Mehrfach-Anfragen bei schnellem Doppel-Antippen.
bool _gastLaeuft = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -56,11 +59,18 @@ class _YoutubeSearchScreenState extends State<YoutubeSearchScreen> {
} }
Future<void> _alsGastFortfahren() async { Future<void> _alsGastFortfahren() async {
if (_gastLaeuft) return;
final gast = context.read<GastZugang>(); final gast = context.read<GastZugang>();
setState(() => _gastFehler = null); setState(() {
_gastFehler = null;
_gastLaeuft = true;
});
final fehler = await gast.holeToken(); final fehler = await gast.holeToken();
if (!mounted) return; if (!mounted) return;
if (fehler != null) setState(() => _gastFehler = fehler); setState(() {
_gastLaeuft = false;
if (fehler != null) _gastFehler = fehler;
});
} }
@override @override
@@ -81,10 +91,10 @@ class _YoutubeSearchScreenState extends State<YoutubeSearchScreen> {
final auth = context.watch<BakaAuth>(); final auth = context.watch<BakaAuth>();
final gast = context.watch<GastZugang>(); final gast = context.watch<GastZugang>();
final suche = context.watch<YtSearchService>(); final suche = context.watch<YtSearchService>();
final istGastModus = !_serverUser && !auth.istAngemeldet && gast.hatToken;
final zugriffOk = auth.istAngemeldet || final zugriffOk = auth.istAngemeldet ||
(_serverUser && !_autoLoginFehlgeschlagen) || (_serverUser && !_autoLoginFehlgeschlagen) ||
gast.hatToken; istGastModus;
final istGastModus = !_serverUser && !auth.istAngemeldet && gast.hatToken;
return SafeArea( return SafeArea(
child: Column( child: Column(
@@ -116,8 +126,12 @@ class _YoutubeSearchScreenState extends State<YoutubeSearchScreen> {
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text( child: Text(
'Die Suche läuft über den Baka-Server. Dafür brauchst du ' _serverUser
'deine Baka-Anmeldung (siehe Download-Tab).', ? 'Die Suche läuft über den Baka-Server. Dafür brauchst '
'du deine Baka-Anmeldung (siehe Download-Tab).'
: 'Die Suche läuft über den Baka-Server. Melde dich mit '
'deinem Baka-Konto an (siehe Download-Tab) oder '
'nutze sie eingeschränkt als Gast.',
style: const TextStyle(color: MeloTheme.text2, fontSize: 13), style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
), ),
), ),
@@ -131,7 +145,7 @@ class _YoutubeSearchScreenState extends State<YoutubeSearchScreen> {
icon: const Icon(Icons.person_outline), icon: const Icon(Icons.person_outline),
label: const Text('Als Gast fortfahren (5 Downloads/Tag)', label: const Text('Als Gast fortfahren (5 Downloads/Tag)',
style: TextStyle(fontSize: 16)), style: TextStyle(fontSize: 16)),
onPressed: _alsGastFortfahren, onPressed: _gastLaeuft ? null : _alsGastFortfahren,
), ),
), ),
), ),
@@ -148,6 +162,9 @@ class _YoutubeSearchScreenState extends State<YoutubeSearchScreen> {
if (istGastModus) if (istGastModus)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
Expanded(
child: Text( child: Text(
gast.verbleibend == null gast.verbleibend == null
? 'Als Gast unterwegs (5 Downloads/Tag)' ? 'Als Gast unterwegs (5 Downloads/Tag)'
@@ -155,6 +172,13 @@ class _YoutubeSearchScreenState extends State<YoutubeSearchScreen> {
style: const TextStyle(color: MeloTheme.text2, fontSize: 13), style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
), ),
), ),
TextButton(
onPressed: gast.verwerfen,
child: const Text('Anmelden'),
),
],
),
),
if (suche.laeuft) const LinearProgressIndicator(), if (suche.laeuft) const LinearProgressIndicator(),
if (suche.fehler != null) if (suche.fehler != null)
Padding( Padding(
+11
View File
@@ -79,4 +79,15 @@ class GastZugang extends ChangeNotifier {
_verbleibend = n; _verbleibend = n;
notifyListeners(); notifyListeners();
} }
/// Verwirft den Gast-Zugang wieder — nötig, damit ein Gast doch noch zum
/// Baka-Konto wechseln kann. Ohne das wäre `_AnmeldeDialog` (der einzige
/// Einstieg in der App für Baka-Login, auch für den Melo-Cloud-Sync)
/// dauerhaft unerreichbar, sobald einmal ein Gast-Token besteht.
Future<void> verwerfen() async {
_token = null;
_verbleibend = null;
await _speicher.loeschen(_tokenKey);
notifyListeners();
}
} }
+28
View File
@@ -334,4 +334,32 @@ void main() {
expect(find.text('Gast-Zugang fehlgeschlagen (500)'), findsOneWidget); expect(find.text('Gast-Zugang fehlgeschlagen (500)'), findsOneWidget);
expect(find.text('YouTube-Adresse'), findsNothing); expect(find.text('YouTube-Adresse'), findsNothing);
}); });
testWidgets(
'Anmelden im Gast-Modus verwirft den Gast-Token und zeigt wieder die '
'Wahl', (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);
await tester.tap(find.text('Anmelden'));
await tester.pumpAndSettle();
expect(find.text('Beim Baka-Konto anmelden'), findsOneWidget);
expect(find.text('YouTube-Adresse'), findsNothing);
expect(gast.hatToken, isFalse);
});
} }
+42 -6
View File
@@ -92,7 +92,7 @@ void main() {
db: db, auth: auth, search: search, download: download)); db: db, auth: auth, search: search, download: download));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.textContaining('Baka-Anmeldung'), findsOneWidget); expect(find.textContaining('Baka-Server'), findsOneWidget);
expect(find.text('Nach Musikvideos suchen'), findsOneWidget); expect(find.text('Nach Musikvideos suchen'), findsOneWidget);
await db.close(); await db.close();
@@ -191,11 +191,15 @@ void main() {
final auth = BakaAuth( final auth = BakaAuth(
client: MockClient((_) async => http.Response('', 500)), client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher()); speicher: _MemorySpeicher());
final search = YtSearchService(auth: auth); final gast = GastZugang(
final download = YtDownloadService(auth: auth); client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher(),
);
final search = YtSearchService(auth: auth, gast: gast);
final download = YtDownloadService(auth: auth, gast: gast);
await tester.pumpWidget(_wrap( await tester.pumpWidget(_wrap(
db: db, auth: auth, search: search, download: download)); db: db, auth: auth, search: search, download: download, gast: gast));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
@@ -208,6 +212,34 @@ void main() {
'Als Gast fortfahren blendet den Baka-Hinweis aus und zeigt den Zähler', 'Als Gast fortfahren blendet den Baka-Hinweis aus und zeigt den Zähler',
(tester) async { (tester) async {
final db = MeloDb(NativeDatabase.memory()); final db = MeloDb(NativeDatabase.memory());
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(),
);
final search = YtSearchService(auth: auth, gast: gast);
final download = YtDownloadService(auth: auth, gast: gast);
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();
});
testWidgets(
'Anmelden im Gast-Modus verwirft den Gast-Token und zeigt wieder den '
'Hinweis', (tester) async {
final db = MeloDb(NativeDatabase.memory());
final auth = BakaAuth( final auth = BakaAuth(
client: MockClient((_) async => http.Response('', 500)), client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher()); speicher: _MemorySpeicher());
@@ -225,10 +257,14 @@ void main() {
await tester.tap(find.text('Als Gast fortfahren (5 Downloads/Tag)')); await tester.tap(find.text('Als Gast fortfahren (5 Downloads/Tag)'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.textContaining('Baka-Anmeldung'), findsNothing);
expect(find.text('Als Gast unterwegs (5 Downloads/Tag)'), findsOneWidget); expect(find.text('Als Gast unterwegs (5 Downloads/Tag)'), findsOneWidget);
await tester.tap(find.text('Anmelden'));
await tester.pumpAndSettle();
expect(find.textContaining('Baka-Server'), findsOneWidget);
expect(gast.hatToken, isFalse);
await db.close(); await db.close();
}); });
} }