UX-Simulation, Teil 3: Abbrechen, Badge, Versionsnummer, Wisch-Strecke
Die vier zuletzt zurückgestellten Punkte aus der UX-Simulation: - Abbrechen-Knopf für hängende YouTube-Downloads (Future.any gegen ein Abbruch-Signal, Anfrage läuft im Hintergrund aus statt die App zu blockieren) - Punkt am inaktiven Download-Unterreiter, wenn dort etwas im Hintergrund läuft (SubTabs bekommt einen optionalen badges-Parameter) - Versionsnummer unter "Über Melo" (neue Abhängigkeit: package_info_plus) - Mini-Player führt beim Wischen jetzt sichtbar mit und wertet zusätzlich zum Schwung auch die zurückgelegte Strecke aus — ein langsames, aber weites Wischen wurde vorher komplett ignoriert Dabei drei bestehende Widget-Tests korrigiert, die den Download-Tab ohne Provider<DownloadService> aufbauten (hauptmenue_test.dart, home_shell_test.dart, online_screen_test.dart) — lief bisher nur, weil niemand danach fragte; das neue Badge tut das jetzt app-weit. 550 Tests grün (vorher 539), flutter analyze ohne Befund.
This commit is contained in:
@@ -5,6 +5,41 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 🧑🤝🧑 UX-Simulation, Teil 3: die zurückgestellten Punkte (2026-08-25)
|
||||||
|
|
||||||
|
Die vier Punkte aus Teil 2, die als "eigenes kleines Vorhaben" zurückgestellt
|
||||||
|
waren, sind jetzt auch erledigt:
|
||||||
|
|
||||||
|
- ⏹️ **Abbrechen-Knopf für YouTube-Downloads.** Ein hängender Download (bis
|
||||||
|
zu zwei mal 180s) ließ sich vorher nur durch Schließen der App stoppen.
|
||||||
|
`YtDownloadService` wartet jetzt mit `Future.any` gegen ein Abbruch-Signal
|
||||||
|
— die Netzwerk-Anfrage läuft im Hintergrund aus, die App wartet aber
|
||||||
|
nicht mehr darauf und meldet sich sofort zurück.
|
||||||
|
- 🔴 **Punkt am inaktiven Download-Unterreiter,** wenn dort im Hintergrund
|
||||||
|
ein Download läuft — man sah vorher nicht, dass beim Wechsel zu
|
||||||
|
YouTube/Server nebenan noch etwas passiert. `SubTabs` kann das jetzt für
|
||||||
|
jeden Reiter einzeln (neuer, optionaler `badges`-Parameter).
|
||||||
|
- 🔢 **Versionsnummer unter "Über Melo"** — dafür kam `package_info_plus`
|
||||||
|
als neue Abhängigkeit dazu (federated plugin, kein zusätzliches
|
||||||
|
Manifest nötig).
|
||||||
|
- 👆 **Mini-Player folgt jetzt dem Finger.** Vorher zählte nur ein kräftiger
|
||||||
|
Schwung (≥ 300 px/s); wer langsam, aber weit genug wischte, bekam gar
|
||||||
|
keine Reaktion — wie ein hängender Bildschirm statt "zu langsam
|
||||||
|
gewischt". Der Player führt die Bewegung jetzt sichtbar mit
|
||||||
|
(`AnimatedContainer.transform`) und entscheidet beim Loslassen über
|
||||||
|
Schwung **oder** zurückgelegte Strecke; ohne Wirkung schnappt er sichtbar
|
||||||
|
zurück. Nach unten bleibt weiterhin unbelegt (Absicht, unverändert).
|
||||||
|
- 🔧 **Nebenbei gefunden:** Drei bestehende Widget-Tests
|
||||||
|
(`hauptmenue_test.dart`, `home_shell_test.dart`,
|
||||||
|
`online_screen_test.dart`) bauten den Download-Tab ohne
|
||||||
|
`Provider<DownloadService>` auf — funktionierte bisher nur, weil niemand
|
||||||
|
ihn dort abfragte. Das Badge tut das jetzt app-weit; die drei
|
||||||
|
Test-Aufbauten sind entsprechend ergänzt.
|
||||||
|
- ✅ **Getestet:** 550 Tests grün (vorher 539), `flutter analyze` ohne
|
||||||
|
Befund. Neu: Abbruch-Test für den YouTube-Download (hängende Anfrage per
|
||||||
|
`Completer`), zwei Tests für das SubTabs-Badge, vier für die
|
||||||
|
streckenbasierten Wisch-Funktionen.
|
||||||
|
|
||||||
### 🧑🤝🧑 UX-Simulation, Teil 2: Player-Feinschliff, Downloads, Einstellungen (2026-08-25)
|
### 🧑🤝🧑 UX-Simulation, Teil 2: Player-Feinschliff, Downloads, Einstellungen (2026-08-25)
|
||||||
|
|
||||||
Zweiter Block derselben 39 Funde:
|
Zweiter Block derselben 39 Funde:
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ class _DownloadsScreenState extends State<DownloadsScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// Fortschrittsanzeigen stecken jeweils nur im aktiven Unterreiter — ohne
|
||||||
|
// diesen Punkt sah man nicht, dass im anderen Bereich gerade ein
|
||||||
|
// Download läuft, während man selbst woanders war.
|
||||||
|
final ytLaeuft = context.watch<YtDownloadService>().laeuft;
|
||||||
|
final serverLaeuft = context.watch<DownloadService>().laeuft;
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -67,6 +72,10 @@ class _DownloadsScreenState extends State<DownloadsScreen> {
|
|||||||
SubTabs(
|
SubTabs(
|
||||||
labels: const ['YouTube', 'Server'],
|
labels: const ['YouTube', 'Server'],
|
||||||
index: _subTab,
|
index: _subTab,
|
||||||
|
badges: [
|
||||||
|
ytLaeuft && _subTab != 0,
|
||||||
|
serverLaeuft && _subTab != 1,
|
||||||
|
],
|
||||||
onChanged: (i) => setState(() => _subTab = i),
|
onChanged: (i) => setState(() => _subTab = i),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -789,8 +798,21 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const LinearProgressIndicator(),
|
const LinearProgressIndicator(),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(dienst.status ?? 'Läuft …',
|
Row(
|
||||||
style: const TextStyle(color: MeloTheme.text2, fontSize: 12)),
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(dienst.status ?? 'Läuft …',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: MeloTheme.text2, fontSize: 12)),
|
||||||
|
),
|
||||||
|
// Ohne diesen Knopf war ein hängender Download (bis zu zwei
|
||||||
|
// mal 180s) nur durch Schließen der App zu stoppen.
|
||||||
|
TextButton(
|
||||||
|
onPressed: dienst.abbrechen,
|
||||||
|
child: const Text('Abbrechen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
if (_meldung != null) ...[
|
if (_meldung != null) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|||||||
@@ -42,19 +42,40 @@ class MiniPlayer extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Leiste extends StatelessWidget {
|
class _Leiste extends StatefulWidget {
|
||||||
const _Leiste({required this.handler, required this.item});
|
const _Leiste({required this.handler, required this.item});
|
||||||
|
|
||||||
final MeloAudioHandler handler;
|
final MeloAudioHandler handler;
|
||||||
final MediaItem item;
|
final MediaItem item;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_Leiste> createState() => _LeisteState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LeisteState extends State<_Leiste> {
|
||||||
|
/// Wie weit der Finger die Leiste gerade zieht — nur zur Anzeige, die
|
||||||
|
/// eigentliche Entscheidung fällt erst bei [onVerticalDragEnd]/
|
||||||
|
/// [onHorizontalDragEnd]. `dy` bleibt <= 0: nach unten ist unbelegt, ein
|
||||||
|
/// Mitführen dorthin würde Wirkung vortäuschen, die es nicht gibt.
|
||||||
|
Offset _versatz = Offset.zero;
|
||||||
|
bool _ziehtGerade = false;
|
||||||
|
|
||||||
void _oeffne(BuildContext context) => Navigator.of(context).push(
|
void _oeffne(BuildContext context) => Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (_) => const NowPlayingScreen()),
|
MaterialPageRoute(builder: (_) => const NowPlayingScreen()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
void _zurueckschnappen() {
|
||||||
|
setState(() {
|
||||||
|
_ziehtGerade = false;
|
||||||
|
_versatz = Offset.zero;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final texte = Theme.of(context).textTheme;
|
final texte = Theme.of(context).textTheme;
|
||||||
|
final handler = widget.handler;
|
||||||
|
final item = widget.item;
|
||||||
return DecoratedBox(
|
return DecoratedBox(
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: MeloTheme.surface,
|
color: MeloTheme.surface,
|
||||||
@@ -68,14 +89,30 @@ class _Leiste extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
// Wischen wie in jeder anderen Musik-App: nach oben öffnet die
|
// Wischen wie in jeder anderen Musik-App: nach oben öffnet die
|
||||||
// Vollbild-Wiedergabe, seitwärts wechselt den Titel.
|
// Vollbild-Wiedergabe, seitwärts wechselt den Titel. Die Leiste
|
||||||
|
// folgt dabei sichtbar dem Finger — sonst blieb ein
|
||||||
|
// bedächtiges, aber weites Wischen (unter der Schwung-Schwelle)
|
||||||
|
// ganz ohne Reaktion.
|
||||||
|
onVerticalDragUpdate: (details) => setState(() {
|
||||||
|
_ziehtGerade = true;
|
||||||
|
final dy = _versatz.dy + details.delta.dy;
|
||||||
|
_versatz = Offset(_versatz.dx, dy > 0 ? 0 : dy);
|
||||||
|
}),
|
||||||
onVerticalDragEnd: (details) {
|
onVerticalDragEnd: (details) {
|
||||||
if (oeffnetVollbild(details.velocity.pixelsPerSecond.dy)) {
|
final oeffnet = oeffnetVollbildMitStrecke(
|
||||||
_oeffne(context);
|
_versatz.dy, details.velocity.pixelsPerSecond.dy);
|
||||||
}
|
_zurueckschnappen();
|
||||||
|
if (oeffnet) _oeffne(context);
|
||||||
},
|
},
|
||||||
|
onHorizontalDragUpdate: (details) => setState(() {
|
||||||
|
_ziehtGerade = true;
|
||||||
|
_versatz = Offset(_versatz.dx + details.delta.dx, _versatz.dy);
|
||||||
|
}),
|
||||||
onHorizontalDragEnd: (details) {
|
onHorizontalDragEnd: (details) {
|
||||||
switch (titelWechsel(details.velocity.pixelsPerSecond.dx)) {
|
final wechsel = titelWechselMitStrecke(
|
||||||
|
_versatz.dx, details.velocity.pixelsPerSecond.dx);
|
||||||
|
_zurueckschnappen();
|
||||||
|
switch (wechsel) {
|
||||||
case Wechsel.naechster:
|
case Wechsel.naechster:
|
||||||
handler.skipToNext();
|
handler.skipToNext();
|
||||||
case Wechsel.vorheriger:
|
case Wechsel.vorheriger:
|
||||||
@@ -86,7 +123,15 @@ class _Leiste extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => _oeffne(context),
|
onTap: () => _oeffne(context),
|
||||||
child: SizedBox(
|
child: AnimatedContainer(
|
||||||
|
// Während des Ziehens sofort folgen (keine Verzögerung);
|
||||||
|
// beim Loslassen ohne Wirkung sichtbar zurückschnappen.
|
||||||
|
duration: _ziehtGerade
|
||||||
|
? Duration.zero
|
||||||
|
: MeloMotion.ruhig(context, MeloMotion.normal),
|
||||||
|
curve: MeloMotion.curve,
|
||||||
|
transform:
|
||||||
|
Matrix4.translationValues(_versatz.dx, _versatz.dy, 0),
|
||||||
height: MiniPlayer.hoehe,
|
height: MiniPlayer.hoehe,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -23,3 +23,28 @@ Wechsel? titelWechsel(double geschwindigkeit) {
|
|||||||
/// Wiedergabe aus Versehen wegschiebt.
|
/// Wiedergabe aus Versehen wegschiebt.
|
||||||
bool oeffnetVollbild(double geschwindigkeit) =>
|
bool oeffnetVollbild(double geschwindigkeit) =>
|
||||||
geschwindigkeit < -wischSchwelle;
|
geschwindigkeit < -wischSchwelle;
|
||||||
|
|
||||||
|
/// Ab welcher zurückgelegten Strecke (in Pixeln) eine langsame, aber
|
||||||
|
/// eindeutige Wischbewegung auch ohne Schwung zählt.
|
||||||
|
///
|
||||||
|
/// Ohne diese Schwelle bekam ein bedächtiges, aber weites Wischen (unter
|
||||||
|
/// [wischSchwelle] Pixel pro Sekunde) überhaupt keine Reaktion — für den
|
||||||
|
/// Nutzer sah das aus wie ein nicht reagierender Bildschirm statt "zu
|
||||||
|
/// langsam gewischt".
|
||||||
|
const wischStreckeSchwelle = 80.0;
|
||||||
|
|
||||||
|
/// Wie [titelWechsel], aber eine ausreichend weite Bewegung zählt zusätzlich
|
||||||
|
/// auch bei geringem Schwung.
|
||||||
|
Wechsel? titelWechselMitStrecke(double strecke, double geschwindigkeit) {
|
||||||
|
final ausSchwung = titelWechsel(geschwindigkeit);
|
||||||
|
if (ausSchwung != null) return ausSchwung;
|
||||||
|
if (strecke.abs() <= wischStreckeSchwelle) return null;
|
||||||
|
return strecke < 0 ? Wechsel.naechster : Wechsel.vorheriger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie [oeffnetVollbild], aber eine ausreichend weite Bewegung nach oben
|
||||||
|
/// zählt zusätzlich auch bei geringem Schwung.
|
||||||
|
bool oeffnetVollbildMitStrecke(double strecke, double geschwindigkeit) {
|
||||||
|
if (oeffnetVollbild(geschwindigkeit)) return true;
|
||||||
|
return strecke < -wischStreckeSchwelle;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import 'baka_auth.dart';
|
|||||||
String sichererDateiname(String titel) =>
|
String sichererDateiname(String titel) =>
|
||||||
titel.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim();
|
titel.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim();
|
||||||
|
|
||||||
|
/// Signalisiert, dass [YtDownloadService.abbrechen] während einer laufenden
|
||||||
|
/// Anfrage aufgerufen wurde.
|
||||||
|
class _Abgebrochen implements Exception {
|
||||||
|
const _Abgebrochen();
|
||||||
|
}
|
||||||
|
|
||||||
/// Ergebnis eines geglückten Downloads.
|
/// Ergebnis eines geglückten Downloads.
|
||||||
class YtErgebnis {
|
class YtErgebnis {
|
||||||
const YtErgebnis({
|
const YtErgebnis({
|
||||||
@@ -41,6 +47,11 @@ class YtDownloadService extends ChangeNotifier {
|
|||||||
String? _status;
|
String? _status;
|
||||||
String? _fehler;
|
String? _fehler;
|
||||||
|
|
||||||
|
/// Ist gesetzt, während [herunterladen] läuft. [abbrechen] löst es aus —
|
||||||
|
/// die Netzwerk-Anfrage selbst läuft im Hintergrund weiter, bis der Socket
|
||||||
|
/// sie beendet, aber die App wartet nicht länger darauf.
|
||||||
|
Completer<void>? _abbruch;
|
||||||
|
|
||||||
bool get laeuft => _laeuft;
|
bool get laeuft => _laeuft;
|
||||||
|
|
||||||
/// Was gerade passiert — für die Anzeige während des Downloads.
|
/// Was gerade passiert — für die Anzeige während des Downloads.
|
||||||
@@ -59,6 +70,20 @@ class YtDownloadService extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bricht den laufenden Download ab, falls einer läuft.
|
||||||
|
void abbrechen() {
|
||||||
|
_abbruch?.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wartet auf [aufgabe], bricht aber sofort mit [_Abgebrochen] ab, sobald
|
||||||
|
/// [abbrechen] aufgerufen wird — ohne auf das Ende von [aufgabe] zu warten.
|
||||||
|
Future<T> _abbrechbar<T>(Future<T> aufgabe, Completer<void> abbruch) {
|
||||||
|
return Future.any<T>([
|
||||||
|
aufgabe,
|
||||||
|
abbruch.future.then((_) => throw const _Abgebrochen()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/// Lädt [url] herunter und legt die MP3 in [zielOrdner] ab.
|
/// Lädt [url] herunter und legt die MP3 in [zielOrdner] ab.
|
||||||
/// Mit [cookies] nutzt der Proxy seine YouTube-Anmeldung — nötig für
|
/// Mit [cookies] nutzt der Proxy seine YouTube-Anmeldung — nötig für
|
||||||
/// altersbeschränkte Videos.
|
/// altersbeschränkte Videos.
|
||||||
@@ -67,6 +92,7 @@ class YtDownloadService extends ChangeNotifier {
|
|||||||
{required String zielOrdner, bool cookies = true}) async {
|
{required String zielOrdner, bool cookies = true}) async {
|
||||||
_fehler = null;
|
_fehler = null;
|
||||||
_laeuft = true;
|
_laeuft = true;
|
||||||
|
final abbruch = _abbruch = Completer<void>();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
if (!auth.istAngemeldet) {
|
if (!auth.istAngemeldet) {
|
||||||
@@ -82,13 +108,22 @@ class YtDownloadService extends ChangeNotifier {
|
|||||||
_melde('Proxy wird gefragt …');
|
_melde('Proxy wird gefragt …');
|
||||||
final http.Response antwort;
|
final http.Response antwort;
|
||||||
try {
|
try {
|
||||||
antwort = await _client
|
antwort = await _abbrechbar(
|
||||||
.post(
|
_client
|
||||||
Uri.parse('$proxyUrl/api/yt-dl'),
|
.post(
|
||||||
headers: {...auth.authHeader, 'Content-Type': 'application/json'},
|
Uri.parse('$proxyUrl/api/yt-dl'),
|
||||||
body: jsonEncode({'url': url, 'cookies': cookies}),
|
headers: {
|
||||||
)
|
...auth.authHeader,
|
||||||
.timeout(const Duration(seconds: 180));
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: jsonEncode({'url': url, 'cookies': cookies}),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 180)),
|
||||||
|
abbruch,
|
||||||
|
);
|
||||||
|
} on _Abgebrochen {
|
||||||
|
_scheitere('Abgebrochen');
|
||||||
|
return null;
|
||||||
} on TimeoutException {
|
} on TimeoutException {
|
||||||
// Anders als bei einem Verbindungsfehler ist der Proxy hier vermutlich
|
// Anders als bei einem Verbindungsfehler ist der Proxy hier vermutlich
|
||||||
// erreichbar, arbeitet nur noch — "nicht erreichbar" würde in die
|
// erreichbar, arbeitet nur noch — "nicht erreichbar" würde in die
|
||||||
@@ -124,14 +159,23 @@ class YtDownloadService extends ChangeNotifier {
|
|||||||
final datei = File('${ordner.path}/${sichererDateiname(titel)}.mp3');
|
final datei = File('${ordner.path}/${sichererDateiname(titel)}.mp3');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final mp3 = await _client
|
final mp3 = await _abbrechbar(
|
||||||
.get(Uri.parse('$proxyUrl$mp3Url'), headers: auth.authHeader)
|
_client
|
||||||
.timeout(const Duration(seconds: 180));
|
.get(Uri.parse('$proxyUrl$mp3Url'), headers: auth.authHeader)
|
||||||
|
.timeout(const Duration(seconds: 180)),
|
||||||
|
abbruch,
|
||||||
|
);
|
||||||
if (mp3.statusCode != 200) {
|
if (mp3.statusCode != 200) {
|
||||||
_scheitere('Herunterladen fehlgeschlagen (${mp3.statusCode})');
|
_scheitere('Herunterladen fehlgeschlagen (${mp3.statusCode})');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
await datei.writeAsBytes(mp3.bodyBytes);
|
await datei.writeAsBytes(mp3.bodyBytes);
|
||||||
|
} on _Abgebrochen {
|
||||||
|
if (await datei.exists()) {
|
||||||
|
await datei.delete();
|
||||||
|
}
|
||||||
|
_scheitere('Abgebrochen');
|
||||||
|
return null;
|
||||||
} on TimeoutException {
|
} on TimeoutException {
|
||||||
if (await datei.exists()) {
|
if (await datei.exists()) {
|
||||||
await datei.delete();
|
await datei.delete();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../downloads/downloads_liste_screen.dart';
|
import '../downloads/downloads_liste_screen.dart';
|
||||||
@@ -32,11 +33,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
late final CacheManager _cache = CacheManager();
|
late final CacheManager _cache = CacheManager();
|
||||||
int _cacheSize = 0;
|
int _cacheSize = 0;
|
||||||
|
|
||||||
|
/// "1.2.3 (45)" — leer, solange noch nicht geladen. Praktisch beim Melden
|
||||||
|
/// von Bugs oder um zu prüfen, ob ein Update angekommen ist.
|
||||||
|
String _version = '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_navidrome.ladeGespeicherteZugangsdaten();
|
_navidrome.ladeGespeicherteZugangsdaten();
|
||||||
_cache.init().then((_) => _updateCacheSize());
|
_cache.init().then((_) => _updateCacheSize());
|
||||||
|
PackageInfo.fromPlatform().then((info) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _version = '${info.version} (${info.buildNumber})');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _updateCacheSize() async {
|
Future<void> _updateCacheSize() async {
|
||||||
@@ -293,10 +303,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
const _SectionLabel('Über Melo'),
|
const _SectionLabel('Über Melo'),
|
||||||
const ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.info_outline),
|
leading: const Icon(Icons.info_outline),
|
||||||
title: Text('Melo'),
|
title: const Text('Melo'),
|
||||||
subtitle: Text('Deine Musik. Offline. Kein Abo.'),
|
subtitle: Text(_version.isEmpty
|
||||||
|
? 'Deine Musik. Offline. Kein Abo.'
|
||||||
|
: 'Deine Musik. Offline. Kein Abo. · Version $_version'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
+35
-10
@@ -13,6 +13,7 @@ class SubTabs extends StatelessWidget {
|
|||||||
required this.labels,
|
required this.labels,
|
||||||
required this.index,
|
required this.index,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
|
this.badges,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<String> labels;
|
final List<String> labels;
|
||||||
@@ -20,6 +21,11 @@ class SubTabs extends StatelessWidget {
|
|||||||
final int index;
|
final int index;
|
||||||
final ValueChanged<int> onChanged;
|
final ValueChanged<int> onChanged;
|
||||||
|
|
||||||
|
/// Punkt neben dem Reiter-Text, an derselben Position je Eintrag wie
|
||||||
|
/// [labels] — z. B. "hier läuft im Hintergrund etwas". `null` oder `false`
|
||||||
|
/// zeigt nichts.
|
||||||
|
final List<bool>? badges;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -33,6 +39,7 @@ class SubTabs extends StatelessWidget {
|
|||||||
child: _Reiter(
|
child: _Reiter(
|
||||||
label: labels[i],
|
label: labels[i],
|
||||||
aktiv: i == index,
|
aktiv: i == index,
|
||||||
|
badge: badges != null && i < badges!.length && badges![i],
|
||||||
onTap: () => onChanged(i),
|
onTap: () => onChanged(i),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -47,10 +54,12 @@ class _Reiter extends StatelessWidget {
|
|||||||
required this.label,
|
required this.label,
|
||||||
required this.aktiv,
|
required this.aktiv,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
this.badge = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String label;
|
final String label;
|
||||||
final bool aktiv;
|
final bool aktiv;
|
||||||
|
final bool badge;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -82,16 +91,32 @@ class _Reiter extends StatelessWidget {
|
|||||||
// vorzudrängen — sonst schweben die Wörter im Nichts.
|
// vorzudrängen — sonst schweben die Wörter im Nichts.
|
||||||
border: aktiv ? null : MeloTheme.hairline,
|
border: aktiv ? null : MeloTheme.hairline,
|
||||||
),
|
),
|
||||||
child: AnimatedDefaultTextStyle(
|
child: Row(
|
||||||
duration: MeloMotion.ruhig(context, MeloMotion.fast),
|
mainAxisSize: MainAxisSize.min,
|
||||||
curve: MeloMotion.curve,
|
children: [
|
||||||
style: TextStyle(
|
AnimatedDefaultTextStyle(
|
||||||
color: aktiv ? MeloTheme.black : MeloTheme.text2,
|
duration: MeloMotion.ruhig(context, MeloMotion.fast),
|
||||||
fontSize: 15,
|
curve: MeloMotion.curve,
|
||||||
height: 1.2,
|
style: TextStyle(
|
||||||
fontWeight: aktiv ? FontWeight.w600 : FontWeight.w500,
|
color: aktiv ? MeloTheme.black : MeloTheme.text2,
|
||||||
),
|
fontSize: 15,
|
||||||
child: Text(label),
|
height: 1.2,
|
||||||
|
fontWeight: aktiv ? FontWeight.w600 : FontWeight.w500,
|
||||||
|
),
|
||||||
|
child: Text(label),
|
||||||
|
),
|
||||||
|
if (badge) ...[
|
||||||
|
const SizedBox(width: MeloSpace.xs),
|
||||||
|
Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: MeloTheme.red,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import device_info_plus
|
|||||||
import file_picker_darwin
|
import file_picker_darwin
|
||||||
import flutter_secure_storage_darwin
|
import flutter_secure_storage_darwin
|
||||||
import just_audio
|
import just_audio
|
||||||
|
import package_info_plus
|
||||||
import record_macos
|
import record_macos
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
@@ -22,6 +23,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||||
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
|
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
|
||||||
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
|
|||||||
@@ -720,6 +720,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
package_info_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: package_info_plus
|
||||||
|
sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "10.2.1"
|
||||||
|
package_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_info_plus_platform_interface
|
||||||
|
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.0"
|
||||||
path:
|
path:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ dependencies:
|
|||||||
shared_preferences: ^2.2.2
|
shared_preferences: ^2.2.2
|
||||||
flutter_secure_storage: ^11.0.0
|
flutter_secure_storage: ^11.0.0
|
||||||
record: ^7.1.1
|
record: ^7.1.1
|
||||||
|
package_info_plus: ^10.2.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import 'package:melo/downloads/downloads_screen.dart';
|
|||||||
import 'package:melo/library/category_service.dart';
|
import 'package:melo/library/category_service.dart';
|
||||||
import 'package:melo/library/database.dart';
|
import 'package:melo/library/database.dart';
|
||||||
import 'package:melo/services/baka_auth.dart';
|
import 'package:melo/services/baka_auth.dart';
|
||||||
|
import 'package:melo/services/download_service.dart';
|
||||||
|
import 'package:melo/services/navidrome_service.dart';
|
||||||
import 'package:melo/services/yt_download_service.dart';
|
import 'package:melo/services/yt_download_service.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
@@ -40,6 +42,11 @@ Widget _wrap(BakaAuth auth,
|
|||||||
client: MockClient((_) async => http.Response('', 500)),
|
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<DownloadService>(
|
||||||
|
create: (_) => DownloadService(db: db, navidrome: NavidromeService()),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: const MaterialApp(home: Scaffold(body: DownloadsScreen())),
|
child: const MaterialApp(home: Scaffold(body: DownloadsScreen())),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import 'package:melo/library/playlist_service.dart';
|
|||||||
import 'package:melo/main.dart';
|
import 'package:melo/main.dart';
|
||||||
import 'package:melo/player/audio_handler.dart';
|
import 'package:melo/player/audio_handler.dart';
|
||||||
import 'package:melo/services/baka_auth.dart';
|
import 'package:melo/services/baka_auth.dart';
|
||||||
|
import 'package:melo/services/download_service.dart';
|
||||||
import 'package:melo/services/melo_cloud_service.dart';
|
import 'package:melo/services/melo_cloud_service.dart';
|
||||||
|
import 'package:melo/services/navidrome_service.dart';
|
||||||
import 'package:melo/services/offline_mode.dart';
|
import 'package:melo/services/offline_mode.dart';
|
||||||
import 'package:melo/services/sync_service.dart';
|
import 'package:melo/services/sync_service.dart';
|
||||||
import 'package:melo/services/yt_download_service.dart';
|
import 'package:melo/services/yt_download_service.dart';
|
||||||
@@ -39,6 +41,7 @@ void main() {
|
|||||||
late AppSettings einstellungen;
|
late AppSettings einstellungen;
|
||||||
late OfflineMode offline;
|
late OfflineMode offline;
|
||||||
late YtDownloadService ytDownload;
|
late YtDownloadService ytDownload;
|
||||||
|
late DownloadService downloads;
|
||||||
late SyncService sync;
|
late SyncService sync;
|
||||||
MeloAudioHandler? handler;
|
MeloAudioHandler? handler;
|
||||||
|
|
||||||
@@ -52,6 +55,7 @@ void main() {
|
|||||||
einstellungen = AppSettings();
|
einstellungen = AppSettings();
|
||||||
offline = OfflineMode();
|
offline = OfflineMode();
|
||||||
ytDownload = YtDownloadService(auth: auth);
|
ytDownload = YtDownloadService(auth: auth);
|
||||||
|
downloads = DownloadService(db: db, navidrome: NavidromeService());
|
||||||
sync = SyncService(db: db, cloud: MeloCloudService(auth: auth));
|
sync = SyncService(db: db, cloud: MeloCloudService(auth: auth));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,6 +87,7 @@ void main() {
|
|||||||
ChangeNotifierProvider<OfflineMode>.value(value: offline),
|
ChangeNotifierProvider<OfflineMode>.value(value: offline),
|
||||||
ChangeNotifierProvider<BakaAuth>.value(value: auth),
|
ChangeNotifierProvider<BakaAuth>.value(value: auth),
|
||||||
ChangeNotifierProvider<YtDownloadService>.value(value: ytDownload),
|
ChangeNotifierProvider<YtDownloadService>.value(value: ytDownload),
|
||||||
|
ChangeNotifierProvider<DownloadService>.value(value: downloads),
|
||||||
ChangeNotifierProvider<SyncService>.value(value: sync),
|
ChangeNotifierProvider<SyncService>.value(value: sync),
|
||||||
],
|
],
|
||||||
child: MaterialApp(theme: MeloTheme.dark, home: const HomeShell()),
|
child: MaterialApp(theme: MeloTheme.dark, home: const HomeShell()),
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import 'package:melo/library/playlist_service.dart';
|
|||||||
import 'package:melo/main.dart';
|
import 'package:melo/main.dart';
|
||||||
import 'package:melo/player/audio_handler.dart';
|
import 'package:melo/player/audio_handler.dart';
|
||||||
import 'package:melo/services/baka_auth.dart';
|
import 'package:melo/services/baka_auth.dart';
|
||||||
|
import 'package:melo/services/download_service.dart';
|
||||||
import 'package:melo/services/melo_cloud_service.dart';
|
import 'package:melo/services/melo_cloud_service.dart';
|
||||||
|
import 'package:melo/services/navidrome_service.dart';
|
||||||
import 'package:melo/services/offline_mode.dart';
|
import 'package:melo/services/offline_mode.dart';
|
||||||
import 'package:melo/services/sync_service.dart';
|
import 'package:melo/services/sync_service.dart';
|
||||||
import 'package:melo/services/yt_download_service.dart';
|
import 'package:melo/services/yt_download_service.dart';
|
||||||
@@ -49,6 +51,8 @@ void main() {
|
|||||||
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
|
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
|
||||||
ChangeNotifierProvider<YtDownloadService>(
|
ChangeNotifierProvider<YtDownloadService>(
|
||||||
create: (_) => YtDownloadService(auth: auth)),
|
create: (_) => YtDownloadService(auth: auth)),
|
||||||
|
ChangeNotifierProvider<DownloadService>(
|
||||||
|
create: (_) => DownloadService(db: db, navidrome: NavidromeService())),
|
||||||
ChangeNotifierProvider<SyncService>(
|
ChangeNotifierProvider<SyncService>(
|
||||||
create: (_) => SyncService(
|
create: (_) => SyncService(
|
||||||
db: db, cloud: MeloCloudService(auth: auth))),
|
db: db, cloud: MeloCloudService(auth: auth))),
|
||||||
|
|||||||
@@ -49,4 +49,47 @@ void main() {
|
|||||||
expect(oeffnetVollbild(0), isFalse);
|
expect(oeffnetVollbild(0), isFalse);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('titelWechselMitStrecke', () {
|
||||||
|
test('kräftiger Schwung reicht wie bisher, egal wie kurz die Strecke',
|
||||||
|
() {
|
||||||
|
expect(titelWechselMitStrecke(-5, -1200), Wechsel.naechster);
|
||||||
|
expect(titelWechselMitStrecke(5, 1200), Wechsel.vorheriger);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine weite, aber langsame Bewegung wechselt trotzdem', () {
|
||||||
|
expect(titelWechselMitStrecke(-wischStreckeSchwelle - 1, 0),
|
||||||
|
Wechsel.naechster);
|
||||||
|
expect(titelWechselMitStrecke(wischStreckeSchwelle + 1, 0),
|
||||||
|
Wechsel.vorheriger);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kurz und langsam wechselt nichts', () {
|
||||||
|
expect(titelWechselMitStrecke(20, 80), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('genau auf der Streckenschwelle wechselt es noch nicht', () {
|
||||||
|
expect(titelWechselMitStrecke(wischStreckeSchwelle, 0), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('oeffnetVollbildMitStrecke', () {
|
||||||
|
test('kräftiger Schwung reicht wie bisher, egal wie kurz die Strecke',
|
||||||
|
() {
|
||||||
|
expect(oeffnetVollbildMitStrecke(-5, -1200), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine weite, aber langsame Bewegung nach oben öffnet trotzdem', () {
|
||||||
|
expect(oeffnetVollbildMitStrecke(-wischStreckeSchwelle - 1, 0), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dieselbe Strecke nach unten öffnet nicht — nur oben ist belegt',
|
||||||
|
() {
|
||||||
|
expect(oeffnetVollbildMitStrecke(wischStreckeSchwelle + 1, 0), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kurz und langsam öffnet nichts', () {
|
||||||
|
expect(oeffnetVollbildMitStrecke(-20, -80), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,28 @@ void main() {
|
|||||||
'Der Server braucht zu lange — bitte später erneut versuchen');
|
'Der Server braucht zu lange — bitte später erneut versuchen');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('abbrechen() beendet einen hängenden Download sofort', () async {
|
||||||
|
// Antwortet nie — genau der Fall, in dem der Nutzer heute nicht mehr
|
||||||
|
// herankam.
|
||||||
|
final haengt = Completer<http.Response>();
|
||||||
|
final dienst = YtDownloadService(
|
||||||
|
auth: await _angemeldeteAuth(),
|
||||||
|
client: MockClient((_) => haengt.future),
|
||||||
|
);
|
||||||
|
|
||||||
|
final ergebnisFuture = dienst.herunterladen('https://youtu.be/dQw4w9WgXcQ',
|
||||||
|
zielOrdner: ziel.path);
|
||||||
|
// Der Service braucht einen Mikrotask, um _abbruch zu setzen, bevor
|
||||||
|
// abbrechen() etwas zu vervollständigen hat.
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
dienst.abbrechen();
|
||||||
|
final ergebnis = await ergebnisFuture;
|
||||||
|
|
||||||
|
expect(ergebnis, isNull);
|
||||||
|
expect(dienst.fehler, 'Abgebrochen');
|
||||||
|
expect(dienst.laeuft, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
test('eine tote Verbindung zum Proxy meldet sich anders als ein Timeout',
|
test('eine tote Verbindung zum Proxy meldet sich anders als ein Timeout',
|
||||||
() async {
|
() async {
|
||||||
final dienst = YtDownloadService(
|
final dienst = YtDownloadService(
|
||||||
|
|||||||
@@ -81,6 +81,57 @@ void main() {
|
|||||||
expect(find.byType(InkWell), findsNWidgets(2));
|
expect(find.byType(InkWell), findsNWidgets(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool istPunkt(Widget w) =>
|
||||||
|
w is Container && (w.decoration as BoxDecoration?)?.shape == BoxShape.circle;
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'ein Punkt zeigt an, dass im anderen Reiter etwas im Hintergrund läuft',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(_wrap(SubTabs(
|
||||||
|
labels: const ['YouTube', 'Server'],
|
||||||
|
index: 0,
|
||||||
|
badges: const [false, true],
|
||||||
|
onChanged: (_) {},
|
||||||
|
)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: find.ancestor(
|
||||||
|
of: find.text('Server'), matching: find.byType(AnimatedContainer)),
|
||||||
|
matching: find.byWidgetPredicate(istPunkt),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: find.ancestor(
|
||||||
|
of: find.text('YouTube'),
|
||||||
|
matching: find.byType(AnimatedContainer)),
|
||||||
|
matching: find.byWidgetPredicate(istPunkt),
|
||||||
|
),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ohne badges-Angabe bleibt ein Reiter ohne Punkt', (tester) async {
|
||||||
|
await tester.pumpWidget(_wrap(SubTabs(
|
||||||
|
labels: const ['Lieder', 'Kategorie'],
|
||||||
|
index: 0,
|
||||||
|
onChanged: (_) {},
|
||||||
|
)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: find.ancestor(
|
||||||
|
of: find.text('Lieder'), matching: find.byType(AnimatedContainer)),
|
||||||
|
matching: find.byWidgetPredicate(istPunkt),
|
||||||
|
),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('der Wechsel wird animiert, nicht gesprungen', (tester) async {
|
testWidgets('der Wechsel wird animiert, nicht gesprungen', (tester) async {
|
||||||
await tester.pumpWidget(_wrap(SubTabs(
|
await tester.pumpWidget(_wrap(SubTabs(
|
||||||
labels: const ['Lieder', 'Kategorie'],
|
labels: const ['Lieder', 'Kategorie'],
|
||||||
|
|||||||
Reference in New Issue
Block a user