Album-Ansicht, mitlaufender Songtext, Cover-Cache, Server-Favoriten
Die letzten vier Punkte aus dem Review-Bericht.
G2 — Server-Bereich: Ein Tipp auf ein Album spielte sofort alles ab;
hineinsehen ging nicht. Jetzt Album- und Kuenstler-Ansicht mit Cover,
Kuenstler, Titelzahl, Gesamtdauer, "Abspielen"/"Zufaellig" und der
Titelliste; Antippen startet an dieser Stelle. Dazu "Mehr Alben laden" —
die Liste hoerte bei 50 auf, ohne dass etwas darauf hinwies.
NICHT umgesetzt: den Server-Bereich aus dem Download-Tab loesen (das
widerspricht Commit 34d6d8e, "Auf Wunsch von Dustin") und eine
Server-Suche (die Suche bleibt ausdruecklich rein lokal).
G8 — Songtext: Traegt der Text Zeitmarken, laeuft er mit; die aktive Zeile
ist hervorgehoben, Antippen springt an die Stelle. Ohne Zeitmarken bleibt
es eine Textwand — die App schaut nach, statt zu raten.
G10 — Cover bleiben liegen. Eigener Ordner, einmal geholt. Der Schluessel
haengt an der Album-Kennung, nicht an der Adresse: die traegt bei jedem
App-Start ein neues Token, und der Ordner waere sonst gewachsen, ohne je
einen Treffer zu liefern. Keine neue Abhaengigkeit.
G3 — Das Herz ist bei Server-Titeln nicht mehr ausgegraut, sondern
favorisiert am Server (star/unstar) — dort, wo der Favoriten-Abgleich sie
auch wieder abholt. Offen bleibt der schwere Teil: Wiedergabezaehler,
Historie und Kategorien braeuchten fuer Server-Titel eine Zeile in der
Bibliothek.
Aus dem Code-Review nachgebessert (sieben Punkte): das Herz behielt beim
Titelwechsel seinen Zustand; recycelte Listenzeilen zeigten kurz das Cover
der vorherigen; der neue Songtext bekam bei Server-Titeln die
Abspiel-Adresse statt der Server-Kennung und fand deshalb nie einen Text;
ein einmal fehlgeschlagenes Cover blieb die ganze Sitzung grau; "Server
nicht erreichbar" sah aus wie "Album hat keine Titel", womit der
Wiederholen-Knopf unerreichbar war; die Kuenstler-Ansicht waere immer leer
geblieben, weil getArtist die Titel gar nicht mitliefert; und zwei
gleichzeitige Cover-Abrufe konnten ein zerschnittenes Bild hinterlassen.
Selbst gefunden: das Haekchen "schon geladen" fragte pro Zeile die Platte
ab, bei jedem Positionswechsel neu. Jetzt einmal je Bildschirm.
457 Tests gruen (vorher 433), flutter analyze ohne Befund, Release-APK
gebaut.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
This commit is contained in:
co-authored by
Claude Opus 5
parent
b42e045e67
commit
0d76d8cc58
@@ -7,7 +7,6 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../library/category_service.dart';
|
||||
import '../library/database.dart';
|
||||
import '../library/library_service.dart';
|
||||
import '../library/song_media.dart';
|
||||
import '../player/audio_handler.dart';
|
||||
import '../services/baka_auth.dart';
|
||||
import '../services/media_store.dart';
|
||||
@@ -17,6 +16,7 @@ import '../services/server_neuheiten.dart';
|
||||
import '../services/yt_download_service.dart';
|
||||
import '../shared/sub_tabs.dart';
|
||||
import 'download_einordnung.dart';
|
||||
import 'server_titel_screen.dart';
|
||||
import '../shared/theme.dart';
|
||||
|
||||
/// Download-Tab: YouTube-Downloader und der verbundene Navidrome-Server
|
||||
@@ -74,9 +74,14 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
||||
// Die Alben sind beim Prüfen schon geladen — kein zweiter Serveraufruf.
|
||||
late final ServerNeuheiten _neuheiten =
|
||||
ServerNeuheiten(holeAlben: () async => _albums);
|
||||
static const _seite = 50;
|
||||
|
||||
List<SubsonicAlbum> _albums = [];
|
||||
List<SubsonicArtist> _artists = [];
|
||||
bool _loading = false;
|
||||
bool _laedtNach = false;
|
||||
/// Der Server hat weniger als eine volle Seite geliefert — dann ist Schluss.
|
||||
bool _alleAlbenDa = false;
|
||||
bool _showArtists = false;
|
||||
String? _error;
|
||||
|
||||
@@ -99,10 +104,11 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final alben = await _nav.getAlben(anzahl: 50);
|
||||
final alben = await _nav.getAlben(anzahl: _seite);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_albums = alben;
|
||||
_alleAlbenDa = alben.length < _seite;
|
||||
_loading = false;
|
||||
});
|
||||
// Erst jetzt prüfen — _albums ist gefüllt.
|
||||
@@ -120,6 +126,29 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Holt die nächste Seite Alben. Vorher blieb die Liste bei 50 stehen,
|
||||
/// ohne dass irgendetwas darauf hinwies.
|
||||
Future<void> _mehrAlben() async {
|
||||
if (_laedtNach || _alleAlbenDa) return;
|
||||
setState(() => _laedtNach = true);
|
||||
try {
|
||||
final weitere =
|
||||
await _nav.getAlben(anzahl: _seite, ab: _albums.length);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_albums = [..._albums, ...weitere];
|
||||
_alleAlbenDa = weitere.length < _seite;
|
||||
_laedtNach = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _laedtNach = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Nachladen fehlgeschlagen: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadArtists() async {
|
||||
if (!_nav.istVerbunden) return;
|
||||
setState(() {
|
||||
@@ -270,15 +299,30 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
||||
tooltip: 'Alles von ${artist.name} herunterladen',
|
||||
holeTitel: () => _nav.getArtistSongs(artist.id),
|
||||
),
|
||||
onTap: () => _playArtist(artist),
|
||||
onTap: () => _oeffneKuenstler(artist),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: _albums.length,
|
||||
itemCount: _albums.length + (_alleAlbenDa ? 0 : 1),
|
||||
itemBuilder: (context, i) {
|
||||
if (i == _albums.length) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: MeloSpace.md),
|
||||
child: Center(
|
||||
child: _laedtNach
|
||||
? const CircularProgressIndicator(
|
||||
color: MeloTheme.red)
|
||||
: OutlinedButton(
|
||||
onPressed: _mehrAlben,
|
||||
child: const Text('Mehr Alben laden'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final album = _albums[i];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
@@ -294,7 +338,7 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
||||
tooltip: 'Album herunterladen',
|
||||
holeTitel: () => _nav.getSongs(album.id),
|
||||
),
|
||||
onTap: () => _playAlbum(album),
|
||||
onTap: () => _oeffneAlbum(album),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -304,74 +348,26 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _playAlbum(SubsonicAlbum album) async {
|
||||
final handler = context.read<MeloAudioHandler>();
|
||||
try {
|
||||
final songs = await _nav.getSongs(album.id);
|
||||
if (songs.isEmpty) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('Album hat keine Songs')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final items = [for (final s in songs) subsonicToMediaItem(s, _nav)];
|
||||
final uebersprungen = await handler.loadPlaylist(items);
|
||||
if (mounted && uebersprungen > 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(offlineHinweis(uebersprungen))),
|
||||
);
|
||||
}
|
||||
} on NichtsOfflineVerfuegbar catch (e) {
|
||||
// Kein Fehler, sondern die gewählte Einstellung — deshalb ohne
|
||||
// "Fehler:" davor, sonst sucht der Nutzer eine Störung.
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('$e')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Fehler: $e')),
|
||||
);
|
||||
}
|
||||
void _oeffneAlbum(SubsonicAlbum album) {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => ServerTitelScreen(
|
||||
titel: album.name,
|
||||
untertitel: album.kuenstler,
|
||||
coverId: album.coverId,
|
||||
navidrome: _nav,
|
||||
holeTitel: () => _nav.getSongs(album.id),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _playArtist(SubsonicArtist artist) async {
|
||||
final handler = context.read<MeloAudioHandler>();
|
||||
try {
|
||||
final songs = await _nav.getArtistSongs(artist.id);
|
||||
if (songs.isEmpty) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Künstler hat keine Songs')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final items = [for (final s in songs) subsonicToMediaItem(s, _nav)];
|
||||
final uebersprungen = await handler.loadPlaylist(items);
|
||||
if (mounted && uebersprungen > 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(offlineHinweis(uebersprungen))),
|
||||
);
|
||||
}
|
||||
} on NichtsOfflineVerfuegbar catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('$e')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Fehler: $e')),
|
||||
);
|
||||
}
|
||||
void _oeffneKuenstler(SubsonicArtist artist) {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => ServerTitelScreen(
|
||||
titel: artist.name,
|
||||
navidrome: _nav,
|
||||
holeTitel: () => _nav.getArtistSongs(artist.id),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../library/database.dart';
|
||||
import '../library/song_media.dart';
|
||||
import '../player/audio_handler.dart';
|
||||
import '../services/navidrome_service.dart';
|
||||
import '../shared/cover.dart';
|
||||
import '../shared/lauf_balken.dart';
|
||||
import '../shared/theme.dart';
|
||||
|
||||
/// Die Titel eines Albums oder Künstlers vom Server.
|
||||
///
|
||||
/// Vorher spielte ein Tipp auf ein Album sofort das ganze Album ab — es gab
|
||||
/// keine Möglichkeit, hineinzusehen oder einen einzelnen Titel zu wählen.
|
||||
/// Das ist der Unterschied zwischen „abspielen" und „öffnen", und jede andere
|
||||
/// Musik-App trennt ihn.
|
||||
class ServerTitelScreen extends StatefulWidget {
|
||||
const ServerTitelScreen({
|
||||
super.key,
|
||||
required this.titel,
|
||||
required this.navidrome,
|
||||
required this.holeTitel,
|
||||
this.untertitel,
|
||||
this.coverId,
|
||||
});
|
||||
|
||||
final String titel;
|
||||
final String? untertitel;
|
||||
final String? coverId;
|
||||
final NavidromeService navidrome;
|
||||
final Future<List<SubsonicSong>> Function() holeTitel;
|
||||
|
||||
@override
|
||||
State<ServerTitelScreen> createState() => _ServerTitelScreenState();
|
||||
}
|
||||
|
||||
class _ServerTitelScreenState extends State<ServerTitelScreen> {
|
||||
List<SubsonicSong>? _songs;
|
||||
Set<String> _geladen = const {};
|
||||
String? _fehler;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_laden();
|
||||
}
|
||||
|
||||
Future<void> _laden() async {
|
||||
final db = context.read<MeloDb>();
|
||||
setState(() => _fehler = null);
|
||||
try {
|
||||
final songs = await widget.holeTitel();
|
||||
// Einmal für den ganzen Bildschirm, nicht je Zeile.
|
||||
final geladen = await db.downloadIds();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_songs = songs;
|
||||
_geladen = geladen;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _fehler = e is NavidromeException
|
||||
? e.message
|
||||
: 'Titel nicht abrufbar');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _spiele(List<SubsonicSong> songs, int ab) async {
|
||||
final handler = context.read<MeloAudioHandler>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final items = [
|
||||
for (final s in songs) subsonicToMediaItem(s, widget.navidrome)
|
||||
];
|
||||
try {
|
||||
final uebersprungen = await handler.loadPlaylist(items, startIndex: ab);
|
||||
if (uebersprungen > 0) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(offlineHinweis(uebersprungen))),
|
||||
);
|
||||
}
|
||||
} on NichtsOfflineVerfuegbar catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text('$e')));
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _zufaellig(List<SubsonicSong> songs) {
|
||||
final gemischt = [...songs]..shuffle();
|
||||
return _spiele(gemischt, 0);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final songs = _songs;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.titel)),
|
||||
body: _fehler != null
|
||||
? _Fehler(text: _fehler!, onNochmal: _laden)
|
||||
: songs == null
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(color: MeloTheme.red))
|
||||
: songs.isEmpty
|
||||
? const Center(
|
||||
child: Text('Keine Titel',
|
||||
style: TextStyle(color: MeloTheme.text2)))
|
||||
: ListView.builder(
|
||||
itemCount: songs.length + 1,
|
||||
itemBuilder: (context, i) {
|
||||
if (i == 0) {
|
||||
return _Kopf(
|
||||
titel: widget.titel,
|
||||
untertitel: widget.untertitel,
|
||||
coverId: widget.coverId,
|
||||
navidrome: widget.navidrome,
|
||||
songs: songs,
|
||||
onAbspielen: () => _spiele(songs, 0),
|
||||
onZufaellig: () => _zufaellig(songs),
|
||||
);
|
||||
}
|
||||
final song = songs[i - 1];
|
||||
return _Zeile(
|
||||
song: song,
|
||||
nummer: i,
|
||||
geladen: _geladen.contains(song.id),
|
||||
onTap: () => _spiele(songs, i - 1),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cover, Anzahl, Gesamtdauer und die beiden Wiedergabe-Knöpfe.
|
||||
class _Kopf extends StatelessWidget {
|
||||
const _Kopf({
|
||||
required this.titel,
|
||||
required this.untertitel,
|
||||
required this.coverId,
|
||||
required this.navidrome,
|
||||
required this.songs,
|
||||
required this.onAbspielen,
|
||||
required this.onZufaellig,
|
||||
});
|
||||
|
||||
final String titel;
|
||||
final String? untertitel;
|
||||
final String? coverId;
|
||||
final NavidromeService navidrome;
|
||||
final List<SubsonicSong> songs;
|
||||
final VoidCallback onAbspielen;
|
||||
final VoidCallback onZufaellig;
|
||||
|
||||
static String _dauer(int sekunden) {
|
||||
final stunden = sekunden ~/ 3600;
|
||||
final minuten = (sekunden % 3600) ~/ 60;
|
||||
if (stunden > 0) return '$stunden Std. $minuten Min.';
|
||||
return '$minuten Min.';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final gesamt = songs.fold<int>(0, (s, e) => s + e.dauerSekunden);
|
||||
final bild = coverId;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
MeloSpace.md, MeloSpace.sm, MeloSpace.md, MeloSpace.sm),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (bild != null)
|
||||
CoverImage(
|
||||
artUri: navidrome.coverUrl(bild),
|
||||
size: 180,
|
||||
radius: MeloRadius.card,
|
||||
),
|
||||
const SizedBox(height: MeloSpace.sm + MeloSpace.xs),
|
||||
Text(titel,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: MeloSpace.xs),
|
||||
Text(
|
||||
[
|
||||
?untertitel,
|
||||
'${songs.length} Titel',
|
||||
_dauer(gesamt),
|
||||
].join(' · '),
|
||||
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: MeloSpace.md),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: const Text('Abspielen'),
|
||||
onPressed: onAbspielen,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: MeloSpace.sm),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.shuffle),
|
||||
label: const Text('Zufällig'),
|
||||
onPressed: onZufaellig,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: MeloSpace.sm),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Titelzeile: Nummer, Name, Dauer — und die Lauf-Markierung, wenn
|
||||
/// dieser Titel gerade spielt.
|
||||
class _Zeile extends StatelessWidget {
|
||||
const _Zeile({
|
||||
required this.song,
|
||||
required this.nummer,
|
||||
required this.geladen,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final SubsonicSong song;
|
||||
final int nummer;
|
||||
final bool geladen;
|
||||
final VoidCallback onTap;
|
||||
|
||||
static String _dauer(int sekunden) {
|
||||
final m = (sekunden ~/ 60).toString();
|
||||
final s = (sekunden % 60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final handler = context.read<MeloAudioHandler>();
|
||||
return StreamBuilder<MediaItem?>(
|
||||
stream: handler.mediaItem,
|
||||
initialData: handler.mediaItem.valueOrNull,
|
||||
builder: (context, laufSnap) {
|
||||
// Server-Titel tragen keine Bibliotheks-UUID; erkannt werden sie an
|
||||
// der Navidrome-ID.
|
||||
final aktuell = laufSnap.data;
|
||||
final markiert =
|
||||
aktuell != null && navidromeIdOf(aktuell) == song.id;
|
||||
return StreamBuilder<PlaybackState>(
|
||||
stream: markiert ? handler.playbackState : null,
|
||||
initialData: handler.playbackState.valueOrNull,
|
||||
builder: (context, zustandSnap) => ListTile(
|
||||
leading: SizedBox(
|
||||
width: 32,
|
||||
child: markiert
|
||||
? LaufBalken(
|
||||
laeuft: zustandSnap.data?.playing ?? false,
|
||||
farbe: MeloTheme.red,
|
||||
)
|
||||
: Text('$nummer',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: MeloTheme.text3)),
|
||||
),
|
||||
title: Text(
|
||||
song.titel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: markiert
|
||||
? const TextStyle(
|
||||
color: MeloTheme.red, fontWeight: FontWeight.w600)
|
||||
: null,
|
||||
),
|
||||
subtitle: song.kuenstler.isEmpty
|
||||
? null
|
||||
: Text(song.kuenstler,
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (geladen)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: MeloSpace.sm),
|
||||
child: Icon(Icons.download_done,
|
||||
size: 16, color: MeloTheme.text3),
|
||||
),
|
||||
Text(_dauer(song.dauerSekunden),
|
||||
style: const TextStyle(color: MeloTheme.text3)),
|
||||
],
|
||||
),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _Fehler extends StatelessWidget {
|
||||
const _Fehler({required this.text, required this.onNochmal});
|
||||
|
||||
final String text;
|
||||
final VoidCallback onNochmal;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: MeloTheme.red),
|
||||
const SizedBox(height: MeloSpace.sm + MeloSpace.xs),
|
||||
Text(text, style: const TextStyle(color: MeloTheme.text2)),
|
||||
const SizedBox(height: MeloSpace.md),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Erneut versuchen'),
|
||||
onPressed: onNochmal,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user