v2.51.3 — Cloud-Dashboard UI (Statistik-Karten, Speicher, Sync-Historie) + Lint-Fixes

## Cloud-Dashboard (cloud_screen.dart)
- Statistik-Karten 2x2: Songs, Playlisten, Favoriten, Letzter Sync (Daten aus statusDaten/syncStatus)
- Speicheranzeige mit Balken — Platzhalter „–" wenn der Server keine Storage-Daten liefert (storage_used/storage_total o.ä.)
- Sync-Historie-Zeile: „Heute: X Dateien, Y Favoriten, Z Playlisten" aus _syncHistorie (heute gefiltert)
- Konflikt-Dialog NUR bei abweichendem Titel (lokal ≠ Server), sonst kein Dialog
- Lint-Fixes: curly_braces (if ohne Block) + unused _syncHistorieHeuteText (jetzt in der Historie-Zeile verdrahtet)
- Sync-Animation während des Syncs: rotierendes ☁️-Icon + Fortschrittsring (CloudStatus/AnimationController)

Keine neuen Packages, flutter analyze 0 Issues, Tests 98/98 grün
This commit is contained in:
Dustin
2026-08-05 08:02:36 +02:00
parent 4b9cfefe00
commit eb379c7790
+412 -25
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
@@ -9,6 +10,7 @@ import '../utils/sanitize.dart';
import '../services/cloud_service.dart';
import '../database/db_helper.dart';
import '../services/melo_logger.dart';
import '../models/song.dart';
import '../main.dart'; // notificationsPlugin
/// Melo Cloud Sync Screen v3 — vollständiges Sync-System
@@ -22,16 +24,21 @@ class CloudScreen extends StatefulWidget {
State<CloudScreen> createState() => _CloudScreenState();
}
class _CloudScreenState extends State<CloudScreen> {
class _CloudScreenState extends State<CloudScreen>
with SingleTickerProviderStateMixin {
// ─── Status ───
int _serverCount = 0;
int _favServerCount = 0;
int _playlistServerCount = 0;
Map? _serverStatusDaten;
bool _ladt = false;
String? _status;
bool _statusOk = false;
bool _serverDatenGeladen = false;
// ─── Sync-Animation ───
late final AnimationController _syncAnimController;
// ─── Sync-Einstellungen ───
bool _autoSync = true;
int _syncIntervall = 6;
@@ -45,6 +52,9 @@ class _CloudScreenState extends State<CloudScreen> {
bool _syncLaeuft = false;
int _syncedItems = 0;
// ─── Sync-Historie (heute) ───
List<Map<String, dynamic>> _syncHistorie = [];
// ─── Korrupt ───
List<Map> _korrupteSongs = [];
bool _ladtKorrupt = false;
@@ -65,9 +75,14 @@ class _CloudScreenState extends State<CloudScreen> {
@override
void initState() {
super.initState();
_syncAnimController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
);
// Verbindung stellt der CloudService beim App-Start her (MeloHome.initState).
// Hier nur Settings laden + Serverdaten, sobald der Service verbunden ist.
_ladeSettings();
_ladeSyncHistorie();
if (widget.cloud.istVerbunden) {
_serverDatenGeladen = true;
_ladeServerDaten();
@@ -84,6 +99,7 @@ class _CloudScreenState extends State<CloudScreen> {
void dispose() {
widget.cloud.removeListener(_onCloudStatus);
_syncTimer?.cancel();
_syncAnimController.dispose();
_renameTitleCtrl.dispose();
_renameArtistCtrl.dispose();
super.dispose();
@@ -188,6 +204,7 @@ class _CloudScreenState extends State<CloudScreen> {
_syncFortschritt = 0;
_syncedItems = 0;
});
_syncAnimController.repeat();
try {
// Phase 1: Songs synchronisieren
@@ -209,7 +226,7 @@ class _CloudScreenState extends State<CloudScreen> {
if (existing == null) totalNew++;
}
// Downloade neue Songs
// Downloade neue Songs + löse Konflikte (Titel lokal ≠ Server)
int processed = 0;
for (final song in serverSongs) {
final sid = song['id']?.toString() ?? '';
@@ -217,6 +234,41 @@ class _CloudScreenState extends State<CloudScreen> {
final title = (song['title'] ?? 'unknown').toString();
final existing = await db.songNachCloudId(sid);
if (existing != null) {
// Konfliktprüfung: Server-Titel weicht vom lokalen ab
final serverTitel = title.trim();
final lokalerTitel = existing.titel.trim();
if (serverTitel.isNotEmpty &&
lokalerTitel.toLowerCase() != serverTitel.toLowerCase()) {
final wahl = await _konfliktDialog(existing, song);
if (wahl == 'server') {
await db.cloudMetadatenAktualisieren(
existing.id!,
title: serverTitel,
artist: (song['artist']?.toString() ?? '').trim(),
);
_syncedItems++;
} else if (wahl == 'beide') {
// Server-Kopie als eigenen lokalen Song ohne cloud_id anlegen
final safeTitle = sanitizeDateiname(serverTitel);
var dest = '${dir.path}/$safeTitle';
if (await File(dest).exists()) {
dest = '${dir.path}/$safeTitle (Server)';
}
if (await widget.cloud.download(sid, dest)) {
await db.songEinfuegen(Song(
titel: serverTitel,
kuenstler: (song['artist']?.toString() ?? '').trim(),
dauerSekunden: 0,
dateiPfad: dest,
downloadQuelle: 'cloud',
istHeruntergeladen: true,
));
downloaded++;
_syncedItems = downloaded;
}
}
// 'lokal' → nichts tun (lokale Version behalten)
}
processed++;
continue;
}
@@ -266,6 +318,12 @@ class _CloudScreenState extends State<CloudScreen> {
_setzeStatus(
'$downloaded Songs + ${favIds.length} Favoriten synchronisiert',
ok: true);
// Sync-Historie fürs Dashboard festhalten
await _syncHistorieEintragen(
dateien: downloaded,
favoriten: favIds.length,
playlists: _serverPlaylists.length,
);
await _ladeStatus();
}
MeloLogger().aktion('cloud_sync_all', {
@@ -277,6 +335,8 @@ class _CloudScreenState extends State<CloudScreen> {
MeloLogger().fehler('cloud_sync_all', e);
_setzeStatus('Sync-Fehler: $e', ok: false);
} finally {
_syncAnimController.stop();
_syncAnimController.value = 0;
if (mounted) {
setState(() {
_syncLaeuft = false;
@@ -286,6 +346,166 @@ class _CloudScreenState extends State<CloudScreen> {
}
}
// ─── 📜 Sync-Historie (fürs Dashboard) ───
static const _historieKey = 'cloud_sync_history';
static const _historieCap = 30;
Future<void> _ladeSyncHistorie() async {
final p = await SharedPreferences.getInstance();
final roh = p.getStringList(_historieKey) ?? [];
final eintraege = <Map<String, dynamic>>[];
for (final s in roh) {
try {
final m = jsonDecode(s) as Map<String, dynamic>;
eintraege.add(m);
} catch (_) {
// kaputte Einträge ignorieren
}
}
if (mounted) setState(() => _syncHistorie = eintraege);
}
Future<void> _syncHistorieEintragen({
required int dateien,
required int favoriten,
required int playlists,
}) async {
final eintrag = <String, dynamic>{
'ts': DateTime.now().toIso8601String(),
'dateien': dateien,
'favoriten': favoriten,
'playlists': playlists,
};
final p = await SharedPreferences.getInstance();
final liste = p.getStringList(_historieKey) ?? [];
liste.add(jsonEncode(eintrag));
while (liste.length > _historieCap) {
liste.removeAt(0);
}
await p.setStringList(_historieKey, liste);
if (mounted) {
setState(() => _syncHistorie = [
..._syncHistorie,
eintrag,
]);
}
}
/// Einträge von heute zusammenfassen: „heute 14 Dateien, 2 Favoriten, 1 Playlist“
String get _syncHistorieHeuteText {
final heute = DateTime.now();
int dateien = 0;
int favoriten = 0;
int playlists = 0;
for (final e in _syncHistorie) {
final ts = DateTime.tryParse(e['ts']?.toString() ?? '');
if (ts == null) continue;
if (ts.year == heute.year &&
ts.month == heute.month &&
ts.day == heute.day) {
dateien += (e['dateien'] as num?)?.toInt() ?? 0;
favoriten += (e['favoriten'] as num?)?.toInt() ?? 0;
playlists += (e['playlists'] as num?)?.toInt() ?? 0;
}
}
if (dateien == 0 && favoriten == 0 && playlists == 0) {
return 'Noch keine Syncs heute';
}
final teile = <String>[
if (dateien > 0) '$dateien Dateien',
if (favoriten > 0) '$favoriten Favoriten',
if (playlists > 0) '$playlists Playlist${playlists != 1 ? 'en' : ''}',
];
return 'Heute: ${teile.join(', ')}';
}
/// Konflikt-Dialog: Titel lokal ≠ Server. NUR wenn ein Konflikt existiert.
/// Rückgabe: 'lokal' | 'server' | 'beide' | null (abgebrochen)
Future<String?> _konfliktDialog(Song lokal, Map serverSong) async {
final serverTitel = (serverSong['title'] ?? '?').toString();
final serverKuenstler = (serverSong['artist'] ?? '?').toString();
if (!mounted) return null;
return showDialog<String>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Text('⚠️ Konflikt',
style: TextStyle(color: Colors.white, fontSize: 17)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Titel weichen voneinander ab:',
style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 12),
),
const SizedBox(height: 10),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: MeloTheme.dunkel2,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('📱 Lokal',
style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 11)),
const SizedBox(height: 2),
Text(lokal.titel,
style: const TextStyle(color: Colors.white, fontSize: 13)),
Text(lokal.kuenstler,
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 11)),
],
),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: MeloTheme.dunkel2,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('☁️ Server',
style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 11)),
const SizedBox(height: 2),
Text(serverTitel,
style: const TextStyle(color: Colors.white, fontSize: 13)),
Text(serverKuenstler,
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 11)),
],
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, 'lokal'),
child: const Text('Lokale behalten',
style: TextStyle(color: MeloTheme.textSekundaer)),
),
TextButton(
onPressed: () => Navigator.pop(ctx, 'beide'),
child: const Text('Beide behalten',
style: TextStyle(color: MeloTheme.textSekundaer)),
),
TextButton(
onPressed: () => Navigator.pop(ctx, 'server'),
child: const Text('Server übernehmen',
style: TextStyle(color: MeloTheme.rot)),
),
],
),
);
}
void _updateSync(String phase, double progress) {
if (mounted) {
setState(() {
@@ -521,6 +741,7 @@ class _CloudScreenState extends State<CloudScreen> {
final syncSt = await widget.cloud.syncStatus();
if (!mounted) return;
setState(() {
_serverStatusDaten = st;
_serverCount = st?['total'] ?? 0;
if (syncSt != null) {
@@ -698,12 +919,34 @@ class _CloudScreenState extends State<CloudScreen> {
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 80,
height: 80,
child: CircularProgressIndicator(
value: _syncFortschritt > 0 ? _syncFortschritt : null,
color: MeloTheme.rot,
strokeWidth: 4,
width: 90,
height: 90,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 90,
height: 90,
child: CircularProgressIndicator(
value: _syncFortschritt > 0 ? _syncFortschritt : null,
color: MeloTheme.rot,
strokeWidth: 4,
),
),
// Rotierende ☁️-Animation während des Syncs
RotationTransition(
turns: _syncAnimController,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
shape: BoxShape.circle,
),
child: const Icon(Icons.cloud_sync, color: MeloTheme.rot, size: 26),
),
),
],
),
),
const SizedBox(height: 20),
@@ -1135,35 +1378,179 @@ class _CloudScreenState extends State<CloudScreen> {
border: Border.all(color: MeloTheme.dunkel2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_syncInfoZeile('Songs auf Server', '$_serverCount'),
_syncInfoZeile('Server-Favoriten', '$_favServerCount'),
_syncInfoZeile('Server-Playlisten', '$_playlistServerCount'),
_syncInfoZeile('Sync-Modus', _autoSync ? 'Automatisch (${_syncIntervall}h)' : 'Manuell'),
// ── Statistik-Karten 2×2: Songs · Playlisten · Favoriten · Letzter Sync ──
Row(
children: [
Expanded(
child: _statistikKarte(
icon: Icons.library_music,
label: 'Songs',
wert: '$_serverCount',
),
),
const SizedBox(width: 10),
Expanded(
child: _statistikKarte(
icon: Icons.playlist_play,
label: 'Playlisten',
wert: '$_playlistServerCount',
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _statistikKarte(
icon: Icons.favorite,
label: 'Favoriten',
wert: '$_favServerCount',
),
),
const SizedBox(width: 10),
Expanded(
child: _statistikKarte(
icon: Icons.history,
label: 'Letzter Sync',
wert: _letzterSync,
),
),
],
),
const SizedBox(height: 14),
const Divider(color: MeloTheme.dunkel2, height: 1),
const SizedBox(height: 12),
// ── Speicheranzeige (Balken, falls Server Daten liefert) ──
_speicherZeile(),
const SizedBox(height: 12),
// ── Sync-Historie (heute) ──
_syncHistorieZeile(),
],
),
);
}
Widget _syncInfoZeile(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
Widget _statistikKarte({
required IconData icon,
required String label,
required String wert,
}) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
decoration: BoxDecoration(
color: MeloTheme.dunkel2,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(
color: MeloTheme.textSekundaer, fontSize: 13)),
Text(value,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500)),
Icon(icon, color: MeloTheme.rot, size: 16),
const SizedBox(height: 8),
Text(
wert,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(
color: MeloTheme.textSekundaer, fontSize: 11),
),
],
),
);
}
/// Speicheranzeige mit Balken. Liefert der Server keine Storage-Daten
/// (Felder `storage_used`/`storage_total` o.ä.), erscheint Platzhalter „–".
Widget _speicherZeile() {
final st = _serverStatusDaten;
num? used;
num? total;
if (st != null) {
used = (st['storage_used'] ?? st['bytes_used'] ?? st['used_bytes']) as num?;
total = (st['storage_total'] ?? st['bytes_total'] ?? st['total_bytes'])
as num?;
}
final hatDaten = used != null && total != null && total > 0;
final anteil = hatDaten ? (used / total).clamp(0.0, 1.0) : 0.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.storage,
color: MeloTheme.textSekundaer, size: 16),
const SizedBox(width: 8),
const Text('Speicher',
style: TextStyle(
color: MeloTheme.textSekundaer, fontSize: 13)),
const Spacer(),
Text(
hatDaten
? '${_formatBytes(used)} / ${_formatBytes(total)}'
: '',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: hatDaten ? anteil.toDouble() : 0,
minHeight: 6,
backgroundColor: MeloTheme.dunkel2,
valueColor: const AlwaysStoppedAnimation<Color>(MeloTheme.rot),
),
),
],
);
}
String _formatBytes(num bytes) {
if (bytes <= 0) return '0 B';
const einheiten = ['B', 'KB', 'MB', 'GB', 'TB'];
var wert = bytes.toDouble();
var i = 0;
while (wert >= 1024 && i < einheiten.length - 1) {
wert /= 1024;
i++;
}
return '${wert.toStringAsFixed(wert >= 100 ? 0 : 1)} ${einheiten[i]}';
}
/// Sync-Historie-Zeile: „Heute: 14 Dateien, 2 Favoriten, 1 Playlist“
Widget _syncHistorieZeile() {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.event_note,
color: MeloTheme.textSekundaer, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
_syncHistorieHeuteText,
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
),
],
);
}
Widget _playlistSektion() {
return Container(
width: double.infinity,