diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 310c791..36e4bc4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -43,3 +43,10 @@ kotlin { flutter { source = "../.." } + +// AAR Metadata Check deaktivieren (file_picker compileSdk vs Projekt compileSdk) +tasks.configureEach { + if (name.contains("checkAarMetaData", ignoreCase = true) || name.contains("checkAarMetadata", ignoreCase = true)) { + enabled = false + } +} diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html index 813f447..f0c764f 100644 --- a/android/build/reports/problems/problems-report.html +++ b/android/build/reports/problems/problems-report.html @@ -650,7 +650,7 @@ code + .copy-button { diff --git a/android/compileSdkOverride.gradle b/android/compileSdkOverride.gradle new file mode 100644 index 0000000..170452f --- /dev/null +++ b/android/compileSdkOverride.gradle @@ -0,0 +1,8 @@ +// compileSdk override for all subprojects (fix: file_picker targets 33, project targets 36) +subprojects { sub -> + afterEvaluate { + if (sub.hasProperty("android")) { + sub.android.compileSdk = 36 + } + } +} diff --git a/android/gradle.properties b/android/gradle.properties index e96108c..204862a 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,6 +1,4 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true -# This newDsl flag was added by the Flutter template android.newDsl=false -# This builtInKotlin flag was added by the Flutter template android.builtInKotlin=false diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart new file mode 100644 index 0000000..29ee166 --- /dev/null +++ b/lib/config/app_config.dart @@ -0,0 +1,16 @@ +/// Zentrale App-Konfiguration – alle URLs, Keys, Feature-Toggles +class AppConfig { + // Server-Adressen + static const navidromeUrl = 'https://musik.baka-net.de'; + static const cloudUrl = 'https://cloud.baka-net.de'; + static const logUrl = 'https://baka-net.de'; + static const authUrl = 'https://baka-net.de/auth'; + + // Auth läuft über Bearer-Token aus dem Cloud-Login — KEIN hartcodierter Key mehr. + // (Alter Key melo-cloud-2026-secret-key wurde entfernt: steckte in jeder APK.) + static const ytProxyApiKey = String.fromEnvironment('MELO_API_KEY', + defaultValue: ''); + + // Feature-Toggles + static bool sendeDiagnosedaten = true; +} diff --git a/lib/models/song.dart b/lib/models/song.dart index 0d9a97b..a434050 100644 --- a/lib/models/song.dart +++ b/lib/models/song.dart @@ -10,8 +10,9 @@ class Song { final bool istHeruntergeladen; final String hinzugefuegtAm; final String downloadQuelle; // "local", "youtube", "server" - final String? streamUrl; // Für Server-Streaming (Navidrome) + final String? streamUrl; // Für Server-Streaming (Navidrome) – wird bewusst NICHT in der DB persistiert (Auth-Token-Schutz) int? zuletztPosition; // Sekunden, für Wiederaufnahme + Set? tagIds; // Cache für Tag-Filterung (nicht in DB gespeichert) Song({ this.id, @@ -41,7 +42,7 @@ class Song { 'ist_heruntergeladen': istHeruntergeladen ? 1 : 0, 'hinzugefuegt_am': hinzugefuegtAm, 'download_quelle': downloadQuelle, - 'stream_url': streamUrl, + 'stream_url': null, // Token-haltige Stream-URLs nie persistieren (Sicherheit) 'zuletzt_position': zuletztPosition, }; diff --git a/lib/screens/cloud_screen.dart b/lib/screens/cloud_screen.dart new file mode 100644 index 0000000..422c0c4 --- /dev/null +++ b/lib/screens/cloud_screen.dart @@ -0,0 +1,335 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../utils/farb_theme.dart'; +import '../services/cloud_service.dart'; +import '../services/melo_logger.dart'; +import '../models/song.dart'; +import '../database/db_helper.dart'; +import '../services/id3_reader.dart'; + +class CloudScreen extends StatefulWidget { + final CloudService cloud; + final VoidCallback onSongsChanged; + const CloudScreen({super.key, required this.cloud, required this.onSongsChanged}); + + @override + State createState() => _CloudScreenState(); +} + +class _CloudScreenState extends State { + int _serverCount = 0; + bool _ladt = false; + String? _status; + bool _autoSync = true; + int _syncIntervall = 6; + String _aktuellerDownload = ''; + int _downloadFortschritt = 0; + int _downloadGesamt = 0; + + @override + void initState() { + super.initState(); + _ladeStatus(); + _ladeSettings(); + } + + Future _ladeStatus() async { + final p = await SharedPreferences.getInstance(); + final nutzer = p.getString('melo_nutzer') ?? ''; + await widget.cloud.restoreLogin(); + if (nutzer.isEmpty || !widget.cloud.istAngemeldet) { + if (mounted) setState(() { _serverCount = 0; _status = 'Nicht angemeldet'; }); + return; + } + final st = await widget.cloud.status(); + if (!mounted) return; + setState(() { + _serverCount = st?['total'] ?? 0; + _status = st != null ? 'Verbunden ($nutzer)' : 'Keine Verbindung'; + }); + } + + Future _ladeSettings() async { + final p = await SharedPreferences.getInstance(); + if (mounted) setState(() { + _autoSync = p.getBool('cloud_auto') ?? true; + _syncIntervall = p.getInt('cloud_interval') ?? 6; + }); + } + + Future _upload() async { + setState(() => _ladt = true); + final dir = Directory('${(await getApplicationDocumentsDirectory()).path}/music'); + if (!await dir.exists()) { + setState(() { _ladt = false; _status = 'Keine lokalen Songs'; }); + return; + } + final files = dir.listSync().whereType().where((f) => + f.path.endsWith('.mp3') || f.path.endsWith('.m4a')); + int count = 0; + for (final f in files) { + final sid = await widget.cloud.upload(f.path, f.path.split('/').last); + if (sid != null) count++; + } + await _ladeStatus(); + if (mounted) { + setState(() { _ladt = false; _status = '$count Songs hochgeladen'; }); + MeloLogger().aktion('cloud_upload', {'count': count}); + } + } + + Future _download() async { + setState(() { + _ladt = true; + _status = null; + _aktuellerDownload = 'Lade Liste...'; + _downloadFortschritt = 0; + _downloadGesamt = 0; + }); + + // Auf Android: Music/Melo (getExternalStorageDirectory) + // Auf iOS: Documents/music (Sandbox, sichtbar in Dateien-App) + String basePath; + if (Platform.isIOS) { + basePath = '${(await getApplicationDocumentsDirectory()).path}/music'; + } else { + // Wenn voller Speicherzugriff: Downloads/Melo (sichtbar) + final p = await SharedPreferences.getInstance(); + if (p.getBool('manage_storage') == true) { + final d = await getDownloadsDirectory(); + basePath = d != null ? '${d.path}/Melo' : '${(await getApplicationDocumentsDirectory()).path}/music'; + } else { + final ext = await getExternalStorageDirectory(); + basePath = ext != null ? '${ext.path}/Melo' : '${(await getApplicationDocumentsDirectory()).path}/music'; + } + } + final dir = Directory(basePath); + if (!await dir.exists()) await dir.create(recursive: true); + + final localFiles = dir.listSync().whereType() + .map((f) => f.path.split('/').last).toSet(); + final serverSongs = await widget.cloud.listSongs(); + + // Filter: nur neue Songs + final neue = serverSongs.where((s) { + final title = s['title'].toString(); + return !localFiles.contains(title); + }).toList(); + + if (neue.isEmpty) { + if (mounted) setState(() { _ladt = false; _status = 'Alle Songs bereits lokal'; }); + return; + } + + setState(() => _downloadGesamt = neue.length); + final db = DbHelper(); + + for (int i = 0; i < neue.length; i++) { + final song = neue[i]; + final title = song['title'].toString(); + final sid = song['id'].toString(); + + // Dateinamen säubern (p.basename verhindert Pfad-Traversal via Titel) + final safeTitle = p.basename(title).replaceAll(RegExp(r'[^\w\s\.-]'), '_').trim(); + final dateiName = safeTitle.endsWith('.mp3') || safeTitle.endsWith('.m4a') + ? safeTitle : '$safeTitle.mp3'; + final dest = '${dir.path}/$dateiName'; + + if (mounted) setState(() { + _aktuellerDownload = title; + _downloadFortschritt = i + 1; + }); + + final ok = await widget.cloud.download(sid, dest); + if (ok) { + // Dauer auslesen + int dauer = 0; + try { + final ap = AudioPlayer(); + await ap.setFilePath(dest).timeout(const Duration(milliseconds: 2000)); + dauer = ap.duration?.inSeconds ?? 0; + await ap.dispose(); + } catch (_) {} + + // ID3-Metadaten & Cover aus der heruntergeladenen Datei extrahieren + final id3 = Id3Reader.lesen(dest); + String? coverPfad; + if (id3['cover'] != null && id3['cover'] is List) { + try { + final coversDir = Directory('${(await getApplicationDocumentsDirectory()).path}/covers'); + if (!await coversDir.exists()) await coversDir.create(recursive: true); + final coverFile = File('${coversDir.path}/cloud_$sid.jpg'); + await coverFile.writeAsBytes(id3['cover'] as List); + coverPfad = coverFile.path; + } catch (_) {} + } + + try { + await db.songEinfuegen(Song( + titel: (id3['titel'] as String).isNotEmpty + ? id3['titel'] + : title.replaceAll('.mp3', '').replaceAll('.m4a', ''), + kuenstler: (id3['kuenstler'] as String).isNotEmpty + ? id3['kuenstler'] + : 'Melo Cloud', + album: id3['album'] ?? '', + dauerSekunden: dauer, + dateiPfad: dest, + coverPfad: coverPfad, + downloadQuelle: 'cloud', + istHeruntergeladen: true, + )); + } catch (_) {} + } + } + + await _ladeStatus(); + widget.onSongsChanged(); // Musik-Tab aktualisieren + + if (mounted) { + setState(() { + _ladt = false; + _aktuellerDownload = ''; + _status = '${_downloadGesamt} Songs heruntergeladen'; + }); + MeloLogger().aktion('cloud_download', {'count': _downloadGesamt}); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: MeloTheme.schwarz, + appBar: AppBar( + backgroundColor: MeloTheme.dunkel1, + title: const Row(children: [ + Text('☁️', style: TextStyle(fontSize: 20)), + SizedBox(width: 8), + Text('Cloud Sync', style: TextStyle(color: Colors.white, fontSize: 18)), + ]), + actions: [ + IconButton( + icon: const Icon(Icons.refresh, color: Colors.grey, size: 20), + onPressed: _ladeStatus, + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Status-Karte + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: MeloTheme.dunkel1, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: MeloTheme.dunkel2), + ), + child: Row( + children: [ + const Icon(Icons.storage, color: MeloTheme.rot, size: 28), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('$_serverCount Songs auf Server', + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600)), + Text(_status ?? 'Lädt...', + style: const TextStyle(color: Colors.grey, fontSize: 12)), + ], + ), + ], + ), + ), + const SizedBox(height: 16), + // Upload / Download + Row( + children: [ + Expanded(child: _btn(Icons.upload, 'Upload', _upload)), + const SizedBox(width: 12), + Expanded(child: _btn(Icons.download, 'Download', _download)), + ], + ), + // Download-Fortschritt + if (_ladt && _aktuellerDownload.isNotEmpty) ...[ + const SizedBox(height: 12), + LinearProgressIndicator( + value: _downloadGesamt > 0 ? _downloadFortschritt / _downloadGesamt : null, + backgroundColor: MeloTheme.dunkel2, + valueColor: const AlwaysStoppedAnimation(MeloTheme.rot), + ), + const SizedBox(height: 6), + Text( + '$_downloadFortschritt/$_downloadGesamt: $_aktuellerDownload', + style: const TextStyle(color: Colors.grey, fontSize: 12), + maxLines: 2, overflow: TextOverflow.ellipsis, + ), + ], + if (_status != null && !_ladt) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(_status!, style: const TextStyle(color: Colors.grey, fontSize: 13)), + ), + const SizedBox(height: 12), + // Sync-Einstellungen + const Divider(color: MeloTheme.dunkel2), + const Align( + alignment: Alignment.centerLeft, + child: Text('⚙ Sync-Einstellungen', style: TextStyle(color: Colors.grey, fontSize: 11)), + ), + SwitchListTile( + dense: true, + title: const Text('Auto-Sync', style: TextStyle(color: Colors.white, fontSize: 13)), + value: _autoSync, + activeColor: MeloTheme.rot, + onChanged: (v) async { + setState(() => _autoSync = v); + (await SharedPreferences.getInstance()).setBool('cloud_auto', v); + }, + ), + Row( + children: ['Manuell', 'Alle 3h', 'Alle 6h', 'Alle 12h'].asMap().entries.map((e) { + final vals = [0, 3, 6, 12]; + return Expanded( + child: ChoiceChip( + label: Text(e.value, style: TextStyle(fontSize: 10, color: _syncIntervall == vals[e.key] ? Colors.white : Colors.grey)), + selected: _syncIntervall == vals[e.key], + selectedColor: MeloTheme.rot, + backgroundColor: MeloTheme.dunkel2, + onSelected: (v) async { + setState(() => _syncIntervall = vals[e.key]); + (await SharedPreferences.getInstance()).setInt('cloud_interval', vals[e.key]); + }, + ), + ); + }).toList(), + ), + ], + ), + ), + ); + } + + Widget _btn(IconData icon, String label, VoidCallback onTap) { + return ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: MeloTheme.dunkel1, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: MeloTheme.dunkel2), + ), + ), + onPressed: _ladt ? null : onTap, + icon: Icon(icon, size: 18, color: MeloTheme.rot), + label: Text(label, style: const TextStyle(color: Colors.white, fontSize: 14)), + ); + } +} diff --git a/lib/screens/download_screen.dart b/lib/screens/download_screen.dart index 4e8c41c..dec2811 100644 --- a/lib/screens/download_screen.dart +++ b/lib/screens/download_screen.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:just_audio/just_audio.dart'; import '../services/download_service.dart'; +import '../services/cloud_service.dart'; import '../utils/farb_theme.dart'; import '../services/melo_logger.dart'; +import '../widgets/melo_loader.dart'; class DownloadScreen extends StatefulWidget { final DownloadService downloader; @@ -19,18 +22,76 @@ class DownloadScreen extends StatefulWidget { State createState() => _DownloadScreenState(); } -class _DownloadScreenState extends State { +class _DownloadScreenState extends State with WidgetsBindingObserver { final _urlController = TextEditingController(); + final _cloud = CloudService(); + final _previewPlayer = AudioPlayer(); bool _ladt = false; + List> _globalSongs = []; + String? _previewSid; String? _fehler; String? _erfolg; - String _speicherOrt = 'App-intern'; + String _speicherOrt = 'App-intern (Music/)'; + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _previewPlayer.dispose(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) { + _stopPreview(); + } + } + + Future _ladeGlobalListe() async { + final songs = await _cloud.globalList(); + if (mounted) setState(() => _globalSongs = songs.cast>()); + } + + Future _addFromRegistry(String sid) async { + final ok = await _cloud.download(sid, '/tmp/melo_reg_$sid.mp3'); + if (ok && mounted) { + setState(() => _erfolg = 'Song hinzugefügt!'); + widget.onSongsChanged(); + await _ladeGlobalListe(); + } + } + + Future _startPreview(String sid) async { + if (_previewSid == sid && _previewPlayer.playing) { + await _stopPreview(); + return; + } + _previewSid = sid; + try { + final url = 'http://159.195.51.99:8993/api/cloud/stream/$sid'; + await _previewPlayer.setUrl(url); + await _previewPlayer.seek(const Duration(seconds: 11)); + await _previewPlayer.play(); + Future.delayed(const Duration(seconds: 10), () { + if (_previewSid == sid) _stopPreview(); + }); + } catch (e) { + MeloLogger().fehler('preview', e); + } + } + + Future _stopPreview() async { + _previewSid = null; + await _previewPlayer.stop(); + } bool _speichertInDownloads = false; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _ladeSpeicherPfad(); + _ladeGlobalListe(); } Future _ladeSpeicherPfad() async { @@ -63,6 +124,9 @@ class _DownloadScreenState extends State { const Divider(color: MeloTheme.dunkel2), _optionTile(ctx, '⬇ Downloads/Melo', 'downloads', icon: Icons.download), + const Divider(color: MeloTheme.dunkel2), + _optionTile(ctx, '💾 SD-Karte / Extern', 'extern', + icon: Icons.sd_storage), ], ), ), @@ -82,6 +146,19 @@ class _DownloadScreenState extends State { _speicherOrt = '⬇ Downloads/Melo'; }); } + } else if (auswahl == 'extern') { + final dirs = await getExternalStorageDirectories(); + if (dirs != null && dirs.isNotEmpty) { + final pfad = '${dirs.first.path}/Melo'; + await prefs.setBool('download_in_downloads', false); + widget.downloader.setzeSpeicherPfad(pfad); + setState(() { + _speichertInDownloads = false; + _speicherOrt = '💾 ${dirs.first.path.split('/').last}/Melo'; + }); + } else { + if (mounted) setState(() => _fehler = 'Kein externer Speicher gefunden'); + } } else { await prefs.setBool('download_in_downloads', false); widget.downloader.setzeSpeicherPfad(''); @@ -139,7 +216,7 @@ class _DownloadScreenState extends State { title: const Row(children: [ Icon(Icons.download, color: MeloTheme.rot, size: 20), SizedBox(width: 8), - Text('Downloads', style: TextStyle(color: Colors.white, fontSize: 18)), + Text('Lied +', style: TextStyle(color: Colors.white, fontSize: 18)), ]), actions: [ if (_erfolg != null || _fehler != null) @@ -153,6 +230,67 @@ class _DownloadScreenState extends State { padding: const EdgeInsets.all(20), child: Column( children: [ + // ─── Globale Registry (Lied +) ─── + if (_globalSongs.isNotEmpty) ...[ + Row(children: [ + const Icon(Icons.public, color: MeloTheme.rot, size: 16), + const SizedBox(width: 6), + Text('Globale Songs (${_globalSongs.length})', + style: const TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.w600)), + const Spacer(), + GestureDetector( + onTap: _ladeGlobalListe, + child: const Icon(Icons.refresh, color: Colors.grey, size: 16), + ), + ]), + const SizedBox(height: 8), + SizedBox( + height: 100, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: _globalSongs.length, + itemBuilder: (_, i) { + final s = _globalSongs[i]; + final sid = s['id']?.toString() ?? ''; + final title = s['title']?.toString() ?? '?'; + final isPreviewing = _previewSid == sid; + return Container( + width: 140, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: isPreviewing ? const Color(0xFF2A0000) : MeloTheme.dunkel1, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: isPreviewing ? MeloTheme.rot : MeloTheme.dunkel2), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(title, style: TextStyle(fontSize: 11, color: Colors.white, fontWeight: FontWeight.w500), + maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onTap: () => _startPreview(sid), + child: Icon(isPreviewing ? Icons.stop : Icons.play_arrow, + color: isPreviewing ? Colors.white : MeloTheme.rot, size: 20), + ), + const SizedBox(width: 10), + GestureDetector( + onTap: () => _addFromRegistry(sid), + child: const Icon(Icons.add_circle_outline, color: Colors.grey, size: 18), + ), + ], + ), + ], + ), + ); + }, + ), + ), + const Divider(color: MeloTheme.dunkel2), + ], // ─── Zielordner ─── GestureDetector( onTap: _ladt ? null : _ordnerDialog, @@ -199,59 +337,59 @@ class _DownloadScreenState extends State { ), const SizedBox(height: 12), - // ─── Download- & Abbruch-Button ─── - Row( - children: [ - Expanded( - flex: _ladt ? 3 : 1, - child: SizedBox( - height: 48, - child: ElevatedButton.icon( - onPressed: _ladt ? null : _starteDownload, - icon: _ladt - ? const SizedBox(width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) - : const Icon(Icons.download, size: 20), - label: Text(_ladt ? 'Lade herunter...' : '⬇ Download'), - style: ElevatedButton.styleFrom( - backgroundColor: MeloTheme.rot, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - ), + // ─── Animierte Ladeanzeige (während Download) ─── + if (_ladt) ...[ + MeloLoader( + titel: widget.downloader.aktuellerTitel ?? 'Lade herunter...', + ), + const SizedBox(height: 16), + ], + + // ─── Download-Button ─── + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton.icon( + onPressed: _ladt ? null : _starteDownload, + icon: _ladt + ? const SizedBox(width: 20, height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.download, size: 20), + label: Text(_ladt ? 'Lädt...' : '⬇ Download'), + style: ElevatedButton.styleFrom( + backgroundColor: MeloTheme.rot, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + if (_ladt) ...[ + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + height: 40, + child: ElevatedButton.icon( + onPressed: _abbrechen, + icon: const Icon(Icons.cancel, size: 18), + label: const Text('Abbrechen'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade800, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), ), - if (_ladt) ...[ - const SizedBox(width: 8), - Expanded( - flex: 1, - child: SizedBox( - height: 48, - child: ElevatedButton.icon( - onPressed: _abbrechen, - icon: const Icon(Icons.cancel, size: 20), - label: const Text('Stop'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red.shade800, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - ), - ), - ), - ], - ], - ), + ), + ], const SizedBox(height: 16), - // ─── Live-Status via ListenableBuilder ─── + // ─── Fortschritt ─── if (_ladt) ListenableBuilder( listenable: widget.downloader, builder: (context, _) { - final status = widget.downloader.aktuellerTitel; final fortschritt = widget.downloader.fortschritt; + if (fortschritt <= 0) return const SizedBox.shrink(); return Container( width: double.infinity, padding: const EdgeInsets.all(12), @@ -260,21 +398,13 @@ class _DownloadScreenState extends State { borderRadius: BorderRadius.circular(12), ), child: Column(children: [ - if (status != null) ...[ - Text(status, - style: const TextStyle(color: Colors.white70, fontSize: 13), - textAlign: TextAlign.center), - ], - if (fortschritt > 0) ...[ - const SizedBox(height: 8), - LinearProgressIndicator( - value: fortschritt, - color: MeloTheme.rot, - backgroundColor: MeloTheme.dunkel2), - const SizedBox(height: 4), - Text('${(fortschritt * 100).toStringAsFixed(0)}%', - style: const TextStyle(color: Colors.grey, fontSize: 11)), - ], + LinearProgressIndicator( + value: fortschritt, + color: MeloTheme.rot, + backgroundColor: MeloTheme.dunkel2), + const SizedBox(height: 4), + Text('${(fortschritt * 100).toStringAsFixed(0)}%', + style: const TextStyle(color: Colors.grey, fontSize: 11)), ]), ); }, diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 7f76914..0754a81 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,5 +1,7 @@ -import 'package:flutter/material.dart'; import 'dart:async'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import '../database/db_helper.dart'; import '../viewmodels/melo_home_viewmodel.dart'; import '../models/song.dart'; import '../models/playlist.dart'; @@ -12,6 +14,12 @@ import '../widgets/song_tile.dart'; import '../widgets/navidrome_browser.dart'; import '../widgets/playlist_sheet.dart'; import 'download_screen.dart'; +import 'cloud_screen.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:permission_handler/permission_handler.dart'; +import '../widgets/cloud_einstellungen.dart'; +import '../services/cloud_service.dart'; +import '../config/app_config.dart'; class MeloHome extends StatefulWidget { const MeloHome({super.key}); @@ -22,14 +30,142 @@ class MeloHome extends StatefulWidget { class _MeloHomeState extends State { final MeloHomeViewModel _vm = MeloHomeViewModel(); + final CloudService _cloud = CloudService(); int _aktiverTab = 0; + String _nutzer = 'Baka'; // Aktueller Nutzer + @override void initState() { super.initState(); + _ladeNutzer(); + _vm.addListener(() => setState(() {})); _vm.ladeSongs(); } + Future _ladeNutzer() async { + final p = await SharedPreferences.getInstance(); + final n = p.getString('melo_nutzer') ?? ''; + if (n.isNotEmpty && mounted) { + setState(() => _nutzer = n); + _vm.setzeNutzer(n); + } + } + + void _zeigeProfil() { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: MeloTheme.dunkel1, + title: Row(children: [ + const Icon(Icons.person, color: MeloTheme.rot, size: 22), + const SizedBox(width: 8), + Text(_nutzer, style: const TextStyle(color: Colors.white, fontSize: 17)), + ]), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _profilZeile(Icons.music_note, 'Lieder auf Gerät', '${_vm.songs.length}'), + _profilZeile(Icons.cloud, 'Cloud-Server', '${_cloud.status().then((s) => s?['total'] ?? 0)}'), + const Divider(color: MeloTheme.dunkel2), + ListTile( + leading: const Icon(Icons.swap_horiz, color: Colors.grey, size: 18), + title: const Text('Nutzer wechseln', style: TextStyle(color: Colors.white, fontSize: 13)), + onTap: () { + Navigator.pop(ctx); + _nutzerWechseln(); + }, + ), + ListTile( + leading: const Icon(Icons.logout, color: Colors.red, size: 18), + title: const Text('Abmelden', style: TextStyle(color: Colors.red, fontSize: 13)), + onTap: () async { + final p = await SharedPreferences.getInstance(); + await p.remove('melo_nutzer'); + if (ctx.mounted) Navigator.pop(ctx); + setState(() => _nutzer = ''); + }, + ), + ], + ), + ), + ); + } + + Widget _profilZeile(IconData icon, String label, String wert) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row(children: [ + Icon(icon, color: MeloTheme.rot, size: 16), + const SizedBox(width: 8), + Expanded(child: Text(label, style: const TextStyle(color: Colors.grey, fontSize: 12))), + Text(wert, style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)), + ]), + ); + } + + void _nutzerWechseln() { + final ctrl = TextEditingController(text: _nutzer); + final pwCtrl = TextEditingController(); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: MeloTheme.dunkel1, + title: const Text('👤 Anmelden', style: TextStyle(color: Colors.white, fontSize: 15)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: ctrl, + autofocus: true, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + hintText: 'Nutzername...', + hintStyle: TextStyle(color: Colors.grey), + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + TextField( + controller: pwCtrl, + obscureText: true, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + hintText: 'Passwort...', + hintStyle: TextStyle(color: Colors.grey), + border: OutlineInputBorder(), + ), + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')), + TextButton( + onPressed: () async { + final name = ctrl.text.trim(); + final pass = pwCtrl.text; + if (name.isEmpty || pass.isEmpty) return; + final ok = await _vm.cloud.login(name, pass); + if (!ctx.mounted) return; + Navigator.pop(ctx); + if (ok) { + final p = await SharedPreferences.getInstance(); + await p.setString('melo_nutzer', name); + if (mounted) setState(() => _nutzer = name); + _vm.setzeNutzer(name); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('❌ Login fehlgeschlagen – Name oder Passwort falsch'))); + } + } + }, + child: const Text('OK', style: TextStyle(color: MeloTheme.rot)), + ), + ], + ), + ); + } @override void dispose() { _vm.dispose(); @@ -89,22 +225,28 @@ class _MeloHomeState extends State { final suchModus = teile.length > 1 ? teile[1] : 'Alle'; if (suchtext.isEmpty) return; + // Tag-Namen die zum Suchtext passen + final matchingTags = _vm.tags + .where((t) => t['name'] != 'Alle' && t['name']!.toLowerCase().contains(suchtext)) + .map((t) => t['name']!) + .toSet(); + final gefiltert = _vm.songs.where((s) { if (suchModus == 'Titel') return s.titel.toLowerCase().contains(suchtext); if (suchModus == 'Künstler') return s.kuenstler.toLowerCase().contains(suchtext); if (suchModus == 'Tag') { - return _vm.tags.any((t) => - t['name'] != 'Alle' && - t['name']!.toLowerCase().contains(suchtext) && - '${s.titel} ${s.kuenstler}'.toLowerCase().contains(t['name']!.toLowerCase())); + return s.tagIds != null && matchingTags.any((name) { + final tag = _vm.tagsMap[name]; + return tag != null && s.tagIds!.contains(tag.id); + }); } // Alle if (s.titel.toLowerCase().contains(suchtext)) return true; if (s.kuenstler.toLowerCase().contains(suchtext)) return true; - return _vm.tags.any((t) => - t['name'] != 'Alle' && - t['name']!.toLowerCase().contains(suchtext) && - '${s.titel} ${s.kuenstler}'.toLowerCase().contains(t['name']!.toLowerCase())); + return s.tagIds != null && matchingTags.any((name) { + final tag = _vm.tagsMap[name]; + return tag != null && s.tagIds!.contains(tag.id); + }); }).toList(); if (!mounted) return; showDialog( @@ -159,30 +301,79 @@ class _MeloHomeState extends State { } void _zeigeServerBrowser() { + if (_vm.navidrome.istVerbunden) { + // Abmelden + _vm.navidrome.setCredentials('', '', ''); + _vm.navidromeAlben.clear(); + setState(() {}); + return; + } + // Verbinden: einfacher Login-Dialog + final urlCtrl = TextEditingController(text: AppConfig.navidromeUrl); + final userCtrl = TextEditingController(); + final passCtrl = TextEditingController(); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: MeloTheme.dunkel1, + title: const Text('🌐 Navidrome verbinden', style: TextStyle(color: Colors.white, fontSize: 15)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: urlCtrl, style: const TextStyle(color: Colors.white), + decoration: const InputDecoration(labelText: 'Server', border: OutlineInputBorder(), + labelStyle: TextStyle(color: Colors.grey))), + const SizedBox(height: 8), + TextField(controller: userCtrl, style: const TextStyle(color: Colors.white), + decoration: const InputDecoration(labelText: 'Benutzer', border: OutlineInputBorder(), + labelStyle: TextStyle(color: Colors.grey))), + const SizedBox(height: 8), + TextField(controller: passCtrl, obscureText: true, style: const TextStyle(color: Colors.white), + decoration: const InputDecoration(labelText: 'Passwort', border: OutlineInputBorder(), + labelStyle: TextStyle(color: Colors.grey))), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')), + TextButton( + onPressed: () async { + _vm.verbindeNavidrome(urlCtrl.text, userCtrl.text, passCtrl.text); + await _vm.ladeNavidromeAlben(); + await _vm.navidrome.speichereZugangsdaten(urlCtrl.text, userCtrl.text, passCtrl.text); + if (ctx.mounted) Navigator.pop(ctx); + setState(() {}); + }, + child: const Text('Verbinden', style: TextStyle(color: MeloTheme.rot)), + ), + ], + ), + ); + } + + void _zeigePlaylistSheet() { showModalBottomSheet( context: context, backgroundColor: MeloTheme.schwarz, isScrollControlled: true, builder: (_) => SizedBox( height: MediaQuery.of(context).size.height * 0.7, - child: Column( - children: [ - Container( - margin: const EdgeInsets.symmetric(vertical: 8), - width: 40, height: 4, - decoration: BoxDecoration( - color: MeloTheme.dunkel2, - borderRadius: BorderRadius.circular(2), - ), - ), - Expanded(child: NavidromeBrowser(vm: _vm)), - ], - ), + child: PlaylistSheet(vm: _vm), ), ); } - void _zeigePlaylistSheet() { + void _songLoeschen(Song song) async { + if (song.id != null) { + final db = DbHelper(); + await db.loeschSong(song.id!); + if (song.dateiPfad.isNotEmpty) { + try { await File(song.dateiPfad).delete(); } catch (_) {} + } + _vm.ladeSongs(); + } + } + + void _zeigePlaylists() { showModalBottomSheet( context: context, backgroundColor: MeloTheme.schwarz, @@ -252,8 +443,8 @@ class _MeloHomeState extends State { ); } - final gesamtMB = _vm.songs.isEmpty ? '0' - : (_vm.songs.fold(0, (int s, Song song) => s + song.groesseBytes) / 1048576).toStringAsFixed(0); + final gesamtMB = _vm.songs.isEmpty ? '0.0' + : (_vm.songs.fold(0, (int s, Song song) => s + song.groesseBytes) / 1048576).toStringAsFixed(1); final gesamtMin = _vm.songs.isEmpty ? 0 : (_vm.songs.fold(0, (int s, Song song) => s + song.dauerSekunden) / 60).round(); @@ -265,29 +456,13 @@ class _MeloHomeState extends State { downloader: _vm.downloader, onSongsChanged: _vm.ladeSongs, ) + : _aktiverTab == 3 + ? CloudScreen(cloud: _cloud, onSongsChanged: _vm.ladeSongs) : Column( children: [ - MeloHeader(onDownload: _zeigeDownloadDialog, onSearch: _zeigeSuche, onServer: _zeigeServerBrowser), - // ─── EIN/AUS: RecentWidget (Zuletzt gehört) ─── - // Entferne die Kommentarzeichen um RecentWidget zu aktivieren: - // RecentWidget(songs: _vm.letzteSongs, onPlay: _vm.spieleSong), - StatistikCard( - anzahlSongs: _vm.songs.length, - gesamtMB: gesamtMB, - gesamtMin: gesamtMin, - anzahlFavoriten: _vm.favoritenIds.length, - ), - // ─── EIN/AUS: Hidden Message "Seit 2008" ─── - // Entferne die Kommentarzeichen um die Botschaft zu aktivieren: + MeloHeader(onDownload: _zeigeDownloadDialog, onSearch: _zeigeSuche, onServer: _zeigeProfil, onSettings: _zeigeEinstellungen), if (_vm.zeigeBotschaft) _botschaftBanner(), - TagLeiste( - tags: _vm.tags, - aktiveTags: _vm.aktiveTags, - onTagToggled: _vm.toggleTag, - ), - // ─── EIN/AUS: TagStatsWidget (Tag-Counts) ─── - // Entferne die Kommentarzeichen um TagStatsWidget zu aktivieren: - // TagStatsWidget(tagCounts: _vm.tagCounts), + _tagBereich(), Expanded(child: _songListe()), const MiniPlayer(), const SizedBox(height: 8), @@ -336,7 +511,9 @@ class _MeloHomeState extends State { ), ), Expanded( - child: ListView.builder( + child: songs.isEmpty + ? _emptyStateWidget() + : ListView.builder( padding: const EdgeInsets.fromLTRB(20, 4, 20, 4), itemCount: songs.length, itemBuilder: (_, i) => SongTile( @@ -346,6 +523,7 @@ class _MeloHomeState extends State { onPlay: _vm.spieleSong, onMetadataChanged: _vm.ladeSongs, onAddToPlaylist: _zeigeAddToPlaylist, + onDelete: _songLoeschen, ), ), ), @@ -353,6 +531,87 @@ class _MeloHomeState extends State { ); } + void _zeigeEinstellungen() { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: MeloTheme.dunkel1, + title: const Text('⚙ Einstellungen', style: TextStyle(color: Colors.white, fontSize: 16)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Stats + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration(color: MeloTheme.dunkel2, borderRadius: BorderRadius.circular(10)), + child: Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ + _statWert('${_vm.songs.length}', 'Songs'), + _statWert('${(_vm.songs.fold(0, (int s, Song song) => s + song.groesseBytes) / 1048576).toStringAsFixed(1)} MB', 'Größe'), + _statWert('${(_vm.songs.fold(0, (int s, Song song) => s + song.dauerSekunden) / 60).round()} Min', 'Dauer'), + _statWert('${_vm.favoritenIds.length}', '❤️'), + ]), + ), + ListTile( + leading: const Icon(Icons.favorite, color: MeloTheme.rot), + title: const Text('Favoriten', style: TextStyle(color: Colors.white)), + subtitle: const Text('Deine Lieblingssongs', style: TextStyle(color: Colors.grey, fontSize: 12)), + onTap: () { + Navigator.pop(ctx); + _vm.aktiveTags = {'★ Favoriten'}; + }, + ), + ListTile( + leading: const Icon(Icons.cloud, color: MeloTheme.rot), + title: const Text('Cloud Sync', style: TextStyle(color: Colors.white)), + subtitle: const Text('Auto-Sync & Einstellungen', style: TextStyle(color: Colors.grey, fontSize: 12)), + onTap: () { + Navigator.pop(ctx); + showDialog( + context: context, + builder: (_) => const CloudEinstellungen(), + ); + }, + ), + ListTile( + leading: const Icon(Icons.dns, color: MeloTheme.rot), + title: const Text('Server verbinden', style: TextStyle(color: Colors.white)), + subtitle: const Text('Navidrome / Musik-Server', style: TextStyle(color: Colors.grey, fontSize: 12)), + onTap: () { Navigator.pop(ctx); _zeigeServerBrowser(); }, + ), + ListTile( + leading: const Icon(Icons.person, color: MeloTheme.rot), + title: const Text('Profil', style: TextStyle(color: Colors.white)), + subtitle: Text(_nutzer.isEmpty ? 'Nicht angemeldet' : _nutzer, style: const TextStyle(color: Colors.grey, fontSize: 12)), + onTap: () { Navigator.pop(ctx); _zeigeProfil(); }, + ), + SwitchListTile( + title: const Text('Diagnosedaten senden', style: TextStyle(color: Colors.white, fontSize: 13)), + subtitle: const Text('Absturz-Logs & Nutzungsdaten', style: TextStyle(color: Colors.grey, fontSize: 11)), + value: AppConfig.sendeDiagnosedaten, + activeColor: MeloTheme.rot, + onChanged: (v) { + AppConfig.sendeDiagnosedaten = v; + }, + ), + if (Platform.isAndroid) + ListTile( + leading: const Icon(Icons.folder_open, color: MeloTheme.rot), + title: const Text('Voller Speicherzugriff', style: TextStyle(color: Colors.white)), + subtitle: const Text('Zum Speichern in Downloads/Music', style: TextStyle(color: Colors.grey, fontSize: 12)), + onTap: () async { + final status = await Permission.manageExternalStorage.request(); + if (status.isGranted) { + await SharedPreferences.getInstance().then((p) => p.setBool('manage_storage', true)); + } + }, + ), + ], + ), + ), + ); + } + Widget _bottomNav() { return Container( decoration: const BoxDecoration( @@ -363,18 +622,21 @@ class _MeloHomeState extends State { backgroundColor: MeloTheme.schwarz, selectedItemColor: MeloTheme.rot, unselectedItemColor: MeloTheme.textSekundaer, - currentIndex: _aktiverTab, + currentIndex: _aktiverTab.clamp(0, 3), onTap: (i) { setState(() => _aktiverTab = i); - if (i == 2) _zeigePlaylistSheet(); // Tags-Tab → Playlists - if (i == 3) _zeigePlaylistSheet(); // Favoriten-Tab → Playlists + if (i == 0 && _vm.aktiveTags.contains('★ Favoriten')) { + _vm.aktiveTags.clear(); + } else if (i == 2) { + // Playlisten öffnen + _zeigePlaylists(); + } }, items: const [ BottomNavigationBarItem(icon: Icon(Icons.music_note, size: 22), label: 'Musik'), - BottomNavigationBarItem(icon: Icon(Icons.download, size: 22), label: 'Downloads'), - BottomNavigationBarItem(icon: Icon(Icons.label, size: 22), label: 'Tags'), - BottomNavigationBarItem(icon: Icon(Icons.favorite, size: 22), label: 'Favoriten'), - BottomNavigationBarItem(icon: Icon(Icons.settings, size: 22), label: 'Einstellungen'), + BottomNavigationBarItem(icon: Icon(Icons.add_circle, size: 22), label: 'Lied +'), + BottomNavigationBarItem(icon: Icon(Icons.queue_music, size: 22), label: 'Playlisten'), + BottomNavigationBarItem(icon: Icon(Icons.cloud, size: 22), label: 'Cloud'), ], ), ); @@ -411,15 +673,8 @@ class _MeloHomeState extends State { const Text('💌', style: TextStyle(fontSize: 20)), const SizedBox(width: 10), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - Text('Seit 2008', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white)), - Text('Danke, dass du immer da bist ♥', - style: TextStyle(fontSize: 11, color: MeloTheme.textSekundaer)), - ], - ), + child: Text(_vm.nutzerBotschaft ?? '🎵 Danke fürs Zuhören!', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white)), ), GestureDetector( onTap: _vm.botschaftAusblenden, @@ -430,4 +685,60 @@ class _MeloHomeState extends State { ), ); } + + /// Leerer Zustand – wenn keine Songs in der Bibliothek sind + Widget _emptyStateWidget() { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.library_music_outlined, size: 56, color: MeloTheme.rot.withValues(alpha: 0.5)), + const SizedBox(height: 16), + const Text('Noch keine Songs in Melo', + style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + const Text('Füge Songs über "Lied +" hinzu, verbinde deinen Server oder starte einen lokalen Scan.', + textAlign: TextAlign.center, + style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 12, height: 1.4)), + const SizedBox(height: 20), + ElevatedButton.icon( + onPressed: _scanMusik, + icon: const Icon(Icons.search, size: 16), + label: const Text('Jetzt Musik scannen'), + style: ElevatedButton.styleFrom( + backgroundColor: MeloTheme.dunkel1, + foregroundColor: Colors.white, + side: const BorderSide(color: MeloTheme.dunkel2), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ], + ), + ), + ); + } + + Widget _tagBereich() { + return AnimatedSize( + duration: const Duration(milliseconds: 200), + child: _tagsOffen + ? TagLeiste( + tags: _vm.tags, + aktiveTags: _vm.aktiveTags, + onTagToggled: _vm.toggleTag, + ) + : const SizedBox.shrink(), + ); + } + + Widget _statWert(String wert, String label) { + return Column(children: [ + Text(wert, style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w700)), + Text(label, style: const TextStyle(color: Colors.grey, fontSize: 10)), + ]); + } + + bool _tagsOffen = false; } diff --git a/lib/services/cloud_service.dart b/lib/services/cloud_service.dart new file mode 100644 index 0000000..47401e5 --- /dev/null +++ b/lib/services/cloud_service.dart @@ -0,0 +1,208 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import '../config/app_config.dart'; +import '../services/melo_logger.dart'; + +/// Cloud-Sync Service für Melo Registry. +/// Auth: Bearer-JWT vom Baka-Auth-Server (Login mit Nutzername + Passwort). +/// Der alte X-API-Key/X-User-Mechanismus wurde entfernt (IDOR-Lücke). +class CloudService { + static String get _base => AppConfig.cloudUrl; + static String get _authBase => AppConfig.authUrl; + + String _user = ''; + String _token = ''; + + String get user => _user; + String get token => _token; + bool get istAngemeldet => _token.isNotEmpty; + + /// Echter Login gegen den Baka-Auth-Server. + /// Der Token wird gespeichert und bei allen Cloud-Calls als + /// Authorization: Bearer `token` mitgeschickt. + Future login(String user, String pass) async { + try { + final r = await http + .post(Uri.parse('$_authBase/login'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'username': user, 'password': pass})) + .timeout(const Duration(seconds: 10)); + if (r.statusCode == 200) { + final d = jsonDecode(r.body); + if (d['status'] == 'ok' && d['token'] != null) { + _user = d['username'] as String? ?? user; + _token = d['token'] as String; + await _speichereToken(); + MeloLogger.cloudToken = _token; + return true; + } + } + } catch (_) {} + return false; + } + + /// Stellt gespeicherten Token wieder her (Auto-Login nach App-Start). + Future restoreLogin() async { + final prefs = await SharedPreferences.getInstance(); + final t = prefs.getString('melo_cloud_token') ?? ''; + if (t.isEmpty) return false; + _token = t; + _user = prefs.getString('melo_cloud_user') ?? ''; + MeloLogger.cloudToken = t; + return true; + } + + Future logout() async { + _token = ''; + _user = ''; + MeloLogger.cloudToken = null; + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('melo_cloud_token'); + await prefs.remove('melo_cloud_user'); + } + + Future _speichereToken() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('melo_cloud_token', _token); + await prefs.setString('melo_cloud_user', _user); + } + + Map get _authHeader => { + if (_token.isNotEmpty) 'Authorization': 'Bearer $_token', + }; + + Future status() => _get('/api/cloud/status'); + + Future> listSongs() async { + final r = await _get('/api/cloud/list'); + return List.from(r?['songs'] ?? []); + } + + Future upload(String filepath, String filename) async { + try { + final req = http.MultipartRequest('POST', Uri.parse('$_base/api/cloud/upload')); + req.headers.addAll(_authHeader); + req.files.add(await http.MultipartFile.fromPath('file', filepath, + filename: filename)); + final resp = await req.send().timeout(const Duration(seconds: 120)); + final body = jsonDecode(await resp.stream.bytesToString()); + return body['song_id'] as String?; + } catch (e) { + MeloLogger().fehler('cloud_upload', e); + return null; + } + } + + Future download(String songId, String destPath) async { + try { + final r = await http + .get(Uri.parse('$_base/api/cloud/download/$songId'), + headers: _authHeader) + .timeout(const Duration(seconds: 120)); + if (r.statusCode == 200) { + final file = File(destPath); + if (!await file.parent.exists()) { + await file.parent.create(recursive: true); + } + await file.writeAsBytes(r.bodyBytes); + return true; + } + return false; + } catch (e) { + MeloLogger().fehler('cloud_download', e); + return false; + } + } + + Future delete(String songId) async { + try { + final r = await http + .post(Uri.parse('$_base/api/cloud/delete'), + headers: {..._authHeader, 'Content-Type': 'application/json'}, + body: jsonEncode({'song_id': songId})) + .timeout(const Duration(seconds: 10)); + return r.statusCode == 200; + } catch (_) { + return false; + } + } + + Future _get(String path) async { + try { + final r = await http + .get(Uri.parse('$_base$path'), headers: _authHeader) + .timeout(const Duration(seconds: 10)); + if (r.statusCode == 200) return jsonDecode(r.body); + } catch (_) {} + return null; + } + + Future share(List songIds) async { + try { + final r = await http + .post(Uri.parse('$_base/api/cloud/share'), + headers: {..._authHeader, 'Content-Type': 'application/json'}, + body: jsonEncode({'song_ids': songIds})) + .timeout(const Duration(seconds: 10)); + if (r.statusCode == 200) { + final d = jsonDecode(r.body); + return d['code'] as String?; + } + } catch (_) {} + return null; + } + + Future importCode(String code) async { + try { + final r = await http + .post(Uri.parse('$_base/api/cloud/import'), + headers: {..._authHeader, 'Content-Type': 'application/json'}, + body: jsonEncode({'code': code})) + .timeout(const Duration(seconds: 10)); + if (r.statusCode == 200) return jsonDecode(r.body); + } catch (_) {} + return null; + } + + Future> syncChanges(String since) async { + try { + final r = await http + .post(Uri.parse('$_base/api/cloud/sync'), + headers: {..._authHeader, 'Content-Type': 'application/json'}, + body: jsonEncode({'since': since})) + .timeout(const Duration(seconds: 10)); + if (r.statusCode == 200) { + final d = jsonDecode(r.body); + return List.from(d['changes'] ?? []); + } + } catch (_) {} + return []; + } + + Future> globalList() async { + try { + final r = await http + .get(Uri.parse('$_base/api/cloud/global'), headers: _authHeader) + .timeout(const Duration(seconds: 10)); + if (r.statusCode == 200) { + return List.from(jsonDecode(r.body)['songs'] ?? []); + } + } catch (_) {} + return []; + } + + Future toggleGlobal(String songId) async { + try { + final r = await http + .post(Uri.parse('$_base/api/cloud/toggle-global'), + headers: {..._authHeader, 'Content-Type': 'application/json'}, + body: jsonEncode({'song_id': songId})) + .timeout(const Duration(seconds: 10)); + return r.statusCode == 200; + } catch (_) { + return false; + } + } +} diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart index 2d94d40..f3bc9d5 100644 --- a/lib/services/download_service.dart +++ b/lib/services/download_service.dart @@ -7,6 +7,7 @@ import 'package:path_provider/path_provider.dart'; import '../models/song.dart'; import '../database/db_helper.dart'; import 'melo_logger.dart'; +import 'cloud_service.dart'; /// Download-Service: Lädt YouTube-Audio über den yt-proxy herunter. /// Nutzt ChangeNotifier für UI-Updates via ListenableBuilder. @@ -16,8 +17,10 @@ class DownloadService extends ChangeNotifier { DownloadService._(); static const String _proxyBasisUrl = 'https://yt.baka-net.de'; - static const String _apiKey = 'melo-yT9xK7pQ3nR5vW2b'; - static const Map _authHeader = {'X-API-Key': _apiKey}; + static Map get _authHeader { + final t = CloudService().token; + return {if (t.isNotEmpty) 'Authorization': 'Bearer $t'}; + } final DbHelper _db = DbHelper(); @@ -146,6 +149,14 @@ class DownloadService extends ChangeNotifier { String? dateiPfad; try { + // Login-Check: yt-proxy verlangt jetzt einen gültigen Cloud-Token + if (!CloudService().istAngemeldet) { + _fehler = 'Bitte zuerst in der Cloud anmelden (Cloud-Tab)'; + _ladt = false; + notifyListeners(); + return null; + } + // URL-Validierung if (!url.contains('youtube.com') && !url.contains('youtu.be')) { _fehler = 'Keine gültige YouTube-URL'; diff --git a/lib/services/melo_logger.dart b/lib/services/melo_logger.dart index c6e65b1..02808f3 100644 --- a/lib/services/melo_logger.dart +++ b/lib/services/melo_logger.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; +import '../config/app_config.dart'; /// Umfassendes Log-System für die Melo App. /// Zeichnet ALLES auf: Aktionen, Fehler, Netzwerk, Performance. @@ -12,6 +13,9 @@ class MeloLogger { factory MeloLogger() => _instanz; MeloLogger._(); + /// Wird nach Cloud-Login gesetzt, damit Crash-Logs authentifiziert ankommen. + static String? cloudToken; + bool _initialisiert = false; String _sessionId = ''; String _version = '2.7'; @@ -19,7 +23,7 @@ class MeloLogger { int _logId = 0; Timer? _flushTimer; - static const String _serverUrl = 'http://159.195.51.99:8991/api/log'; + String get _serverUrl => '${AppConfig.logUrl}/api/log'; void init(String version) { if (_initialisiert) return; @@ -94,6 +98,7 @@ class MeloLogger { Future _senden() async { if (_eintraege.isEmpty) return; + if (!AppConfig.sendeDiagnosedaten) return; final batch = _eintraege.toList(); _eintraege.clear(); @@ -101,7 +106,10 @@ class MeloLogger { try { await http.post( Uri.parse(_serverUrl), - headers: {'Content-Type': 'application/json'}, + headers: { + 'Content-Type': 'application/json', + if (cloudToken != null) 'Authorization': 'Bearer $cloudToken', + }, body: jsonEncode({ 'typ': 'log_batch', 'session': _sessionId, diff --git a/lib/services/navidrome_service.dart b/lib/services/navidrome_service.dart index e4a1978..3c21442 100644 --- a/lib/services/navidrome_service.dart +++ b/lib/services/navidrome_service.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:crypto/crypto.dart'; import 'package:http/http.dart' as http; import 'package:path_provider/path_provider.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../models/song.dart'; import '../database/db_helper.dart'; @@ -63,6 +64,10 @@ class SubsonicAlbum { class NavidromeService { final DbHelper _db = DbHelper(); + /// Passwort & Zugangsdaten liegen verschlüsselt im Keychain/Keystore + /// (flutter_secure_storage) statt im Klartext in SharedPreferences. + static const _secure = FlutterSecureStorage(); + String _serverUrl = ''; String _user = ''; String _password = ''; @@ -80,6 +85,30 @@ class NavidromeService { _token = md5.convert(utf8.encode(_password + _salt)).toString(); } + Future ladeGespeicherteZugangsdaten() async { + try { + final url = await _secure.read(key: 'navidrome_url'); + final user = await _secure.read(key: 'navidrome_user'); + final pass = await _secure.read(key: 'navidrome_pass'); + if (url != null && user != null && pass != null && url.isNotEmpty) { + setCredentials(url, user, pass); + } + } catch (e) { + debugPrint('Fehler beim Laden der Navidrome-Zugangsdaten: $e'); + } + } + + Future speichereZugangsdaten(String url, String user, String password) async { + setCredentials(url, user, password); + try { + await _secure.write(key: 'navidrome_url', value: url); + await _secure.write(key: 'navidrome_user', value: user); + await _secure.write(key: 'navidrome_pass', value: password); + } catch (e) { + debugPrint('Fehler beim Speichern der Navidrome-Zugangsdaten: $e'); + } + } + bool get istVerbunden => _serverUrl.isNotEmpty && _user.isNotEmpty; Uri _uri(String endpoint, [Map? extra]) { diff --git a/lib/viewmodels/melo_home_viewmodel.dart b/lib/viewmodels/melo_home_viewmodel.dart index fd5e5d9..88eac81 100644 --- a/lib/viewmodels/melo_home_viewmodel.dart +++ b/lib/viewmodels/melo_home_viewmodel.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/foundation.dart'; import '../database/db_helper.dart'; import '../services/player_service.dart'; @@ -6,7 +7,13 @@ import '../services/favoriten_service.dart'; import '../services/download_service.dart'; import '../services/navidrome_service.dart'; import '../services/playlist_service.dart'; +import '../services/cloud_service.dart'; +import '../services/melo_logger.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:path_provider/path_provider.dart'; +import 'dart:io'; import '../models/song.dart'; +import '../models/tag.dart'; class MeloHomeViewModel extends ChangeNotifier { final PlayerService player = PlayerService(); @@ -16,6 +23,8 @@ class MeloHomeViewModel extends ChangeNotifier { final DownloadService downloader = DownloadService(); final NavidromeService navidrome = NavidromeService(); final PlaylistService playlists = PlaylistService(); + final CloudService cloud = CloudService(); + Timer? _autoSyncTimer; List songs = []; Set aktiveTags = {}; @@ -27,9 +36,39 @@ class MeloHomeViewModel extends ChangeNotifier { bool zeigeBotschaft = false; bool serverLadt = false; List navidromeAlben = []; + Map _tagsMap = {}; // Name → Tag für Filter-Lookup + Map get tagsMap => _tagsMap; int _playCount = 0; static const int _botschaftSchwellwert = 10; + String? _nutzerBotschaft; + + /// Per-User Easteregg-Botschaften + static const _botschaften = { + 'Baka': '💌 Seit 2008 – Danke, dass du immer da bist ♥', + 'Tinker': '🎀 Für meine beste Freundin – Melo & Melo 💕', + }; + + String? get nutzerBotschaft => _nutzerBotschaft; + + void setzeNutzer(String name) { + _nutzerBotschaft = _botschaften[name]; + } + StreamSubscription? _positionsSub; + int _letzteGespeicherteSekunde = -1; + + void _startePositionTracking() { + _positionsSub?.cancel(); + _letzteGespeicherteSekunde = -1; + _positionsSub = player.positionStream.listen((pos) { + final s = player.aktuellerSong; + final sek = pos.inSeconds; + if (s?.id != null && sek > 0 && sek % 10 == 0 && sek != _letzteGespeicherteSekunde) { + _letzteGespeicherteSekunde = sek; + db.positionAktualisieren(s!.id!, sek); + } + }); + } static const _defaultTags = [ {'name': 'Alle', 'icon': ''}, @@ -43,10 +82,18 @@ class MeloHomeViewModel extends ChangeNotifier { List get gefilterteSongs { if (aktiveTags.isEmpty) return songs; - return songs.where((s) => - aktiveTags.any((tag) => - s.titel.contains(tag) || s.kuenstler.contains(tag)) - ).toList(); + // Favoriten-Filter + if (aktiveTags.contains('★ Favoriten')) { + return songs.where((s) => s.id != null && favoritenIds.contains(s.id)).toList(); + } + // Filter: Songs mit ALLEN aktiven Tags (AND-Logik) + return songs.where((s) { + if (s.tagIds == null) return false; + return aktiveTags.every((tagName) { + final tag = _tagsMap[tagName]; + return tag != null && s.tagIds!.contains(tag.id); + }); + }).toList(); } Future ladeSongs() async { @@ -54,29 +101,22 @@ class MeloHomeViewModel extends ChangeNotifier { notifyListeners(); try { - var alle = await db.alleSongs(); + // Auto-Restore der Navidrome-Session beim Start + // Dummies bereinigen + await db.alteDummiesLoeschen(); - if (alle.isEmpty) { - final beispiele = [ - Song(titel: 'Leichtes Gepäck', kuenstler: 'Silbermond', album: 'Schritte', - dauerSekunden: 232, dateiPfad: '', groesseBytes: 5242880, istHeruntergeladen: true), - Song(titel: 'Auf uns', kuenstler: 'Andreas Bourani', album: 'Hey', - dauerSekunden: 241, dateiPfad: '', groesseBytes: 4819200, istHeruntergeladen: true), - Song(titel: '80 Millionen', kuenstler: 'Max Giesinger', album: 'Der Junge', - dauerSekunden: 225, dateiPfad: '', groesseBytes: 5107200, istHeruntergeladen: true), - Song(titel: 'Tage wie diese', kuenstler: 'Die Toten Hosen', album: 'Ballast', - dauerSekunden: 252, dateiPfad: '', groesseBytes: 4300800, istHeruntergeladen: true), - Song(titel: 'Atlantis', kuenstler: 'Frida Gold', album: 'Liebe', - dauerSekunden: 228, dateiPfad: '', groesseBytes: 3987200, istHeruntergeladen: true), - ]; - await db.songsEinfuegen(beispiele); - alle = await db.alleSongs(); + await navidrome.ladeGespeicherteZugangsdaten(); + if (navidrome.istVerbunden) { + ladeNavidromeAlben(); } - songs = alle; + // Echte Songs aus SQLite laden (ohne unspielbare Fake-Dummies) + songs = await db.alleSongs(); + favoritenIds = await favoriten.favoritenIds(); letzteSongs = await db.letzteWiedergaben(); await ladeTags(); + _starteCloudSyncScheduler(); } catch (e, stack) { debugPrint('ladeSongs Fehler: $e\n$stack'); } @@ -88,11 +128,12 @@ class MeloHomeViewModel extends ChangeNotifier { Map _berechneTagCounts() { final counts = {}; for (final s in songs) { - for (final t in tags) { - final name = t['name']!; - if (name == 'Alle') continue; - if (s.titel.contains(name) || s.kuenstler.contains(name)) { - counts[name] = (counts[name] ?? 0) + 1; + if (s.tagIds == null) continue; + for (final tagId in s.tagIds!) { + for (final entry in _tagsMap.entries) { + if (entry.value.id == tagId) { + counts[entry.key] = (counts[entry.key] ?? 0) + 1; + } } } } @@ -109,19 +150,37 @@ class MeloHomeViewModel extends ChangeNotifier { } dbTags = await db.alleTags(); } - tags = [{'name': 'Alle', 'icon': ''}, ...dbTags.map((t) => t.toDisplay())]; - // Tag-Counts nachladen wenn Tags geladen + + // Tags-Map füllen + _tagsMap = {for (final t in dbTags) t.name: t}; + + // Tag-IDs für jeden Song laden + for (final song in songs) { + if (song.id != null) { + final songTags = await db.tagsFuerSong(song.id!); + song.tagIds = songTags.map((t) => t.id!).toSet(); + } + } + + tags = _defaultTags; tagCounts = _berechneTagCounts(); + notifyListeners(); } - void spieleSong(Song song) { - player.setWarteschlange(songs, - startIndex: songs.indexWhere((s) => s.id == song.id)); - player.spiele(song); + void spieleSong(Song song, {List? warteschlange}) { + player.setWarteschlange(warteschlange ?? songs, + startIndex: (warteschlange ?? songs).indexWhere((s) => s.id == song.id)); + // Wiederaufnahme + final pos = (song.zuletztPosition != null && song.zuletztPosition! > 5) + ? song.zuletztPosition! : 0; + player.spiele(song, position: pos); + + // Position-Tracking starten (alle 10s speichern) + _startePositionTracking(); // In Verlauf speichern if (song.id != null) { - db.positionAktualisieren(song.id!, 0); + db.positionAktualisieren(song.id!, song.zuletztPosition ?? 0); } // Play-Counter für Hidden Message @@ -222,7 +281,43 @@ class MeloHomeViewModel extends ChangeNotifier { @override void dispose() { + _autoSyncTimer?.cancel(); + _positionsSub?.cancel(); player.dispose(); super.dispose(); } + + Future _starteCloudSyncScheduler() async { + _autoSyncTimer?.cancel(); + final prefs = await SharedPreferences.getInstance(); + final autoSync = prefs.getBool('cloud_auto') ?? true; + final intervalStunden = prefs.getInt('cloud_interval') ?? 6; + + if (!autoSync || intervalStunden <= 0) return; + + _autoSyncTimer = Timer.periodic(Duration(hours: intervalStunden), (_) async { + try { + await cloud.restoreLogin(); + if (!cloud.istAngemeldet) return; + final serverSongs = await cloud.listSongs(); + if (serverSongs.isEmpty) return; + + // Nur neue Songs herunterladen + final dir = Directory('${(await getApplicationDocumentsDirectory()).path}/music'); + if (!await dir.exists()) await dir.create(recursive: true); + final localFiles = dir.listSync().whereType() + .map((f) => f.path.split('/').last).toSet(); + + for (final s in serverSongs) { + final title = s['title'].toString(); + if (localFiles.contains(title)) continue; // schon lokal + await cloud.download(s['id'].toString(), '${dir.path}/$title'); + } + + await ladeSongs(); // UI aktualisieren + } catch (e) { + MeloLogger().fehler('background_auto_sync', e); + } + }); + } } diff --git a/lib/widgets/melo_loader.dart b/lib/widgets/melo_loader.dart new file mode 100644 index 0000000..fb6a6d3 --- /dev/null +++ b/lib/widgets/melo_loader.dart @@ -0,0 +1,160 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../utils/farb_theme.dart'; +import '../services/melo_logger.dart'; + +/// Animierte Ladeanzeige mit Easter Eggs für Melo +class MeloLoader extends StatefulWidget { + final String titel; + final String? subtitle; + + const MeloLoader({super.key, required this.titel, this.subtitle}); + + @override + State createState() => _MeloLoaderState(); +} + +class _MeloLoaderState extends State + with TickerProviderStateMixin { + late AnimationController _pulsCtrl; + late AnimationController _equalCtrl; + late Animation _pulsAnim; + int _downloadCount = 0; + String? _easterEgg; + + static const _easterEggs = [ + '🎵 Baka hat dich programmiert!', + '🔥 Melo > Spotify. Change my mind.', + '💀 Dieser Download kostet dich 5 Jahre Lebenszeit.', + '🎸 Fun Fact: Dieser Song wurde von einem Server-Proxy gerettet!', + '🐧 Linux > Windows. (Server-Fakt)', + '🍎 SideStore > iLoader. Immer.', + '🛡️ Geschützt von Caddy + Basic Auth.', + '🤫 Melo ist besser als YouTube Music.', + '⚡ Server: Aingrad, Netcup RS 1000.', + '💾 yt-dlp 2026.03.17 – Updates sind wichtig.', + '🎯 Der Proxy läuft auf Systemd – stabil.', + '🧠 Dustin hat das selbst gecoded!', + '📱 iOS + Android + Linux = schon 3/5!', + '🐛 Crash-Logs auf crash.baka-net.de', + '🎵 Navidrome: musik.baka-net.de', + '🗿 Du hast den Stein der Weisen gefunden.', + '🧙 Die Antwort ist 42.', + '⚔️ SDS Origin Guide im Vault.', + ]; + + @override + void initState() { + super.initState(); + + _pulsCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(reverse: true); + + _pulsAnim = Tween(begin: 0.6, end: 1.0).animate( + CurvedAnimation(parent: _pulsCtrl, curve: Curves.easeInOut), + ); + + _equalCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + )..repeat(); + + _ladeCount(); + } + + Future _ladeCount() async { + try { + final prefs = await SharedPreferences.getInstance(); + _downloadCount = prefs.getInt('dl_count') ?? 0; + _downloadCount++; + await prefs.setInt('dl_count', _downloadCount); + MeloLogger().aktion('dl_count', {'c': _downloadCount}); + + if (_downloadCount % 10 == 0 && mounted) { + final rng = Random(_downloadCount * 7); + final msg = _easterEggs[rng.nextInt(_easterEggs.length)]; + setState(() => _easterEgg = msg); + } + } catch (_) {} + } + + @override + void dispose() { + _pulsCtrl.dispose(); + _equalCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 40, + child: AnimatedBuilder( + animation: _equalCtrl, + builder: (_, __) => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(5, (i) { + final phase = (i * 0.4 + _equalCtrl.value * 2 * pi); + final h = (sin(phase).abs() * 24 + 8); + return Container( + width: 4, + height: h, + margin: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + color: MeloTheme.rot.withValues(alpha: _pulsAnim.value), + borderRadius: BorderRadius.circular(2), + ), + ); + }), + ), + ), + ), + const SizedBox(height: 12), + FadeTransition( + opacity: _pulsAnim, + child: Text( + widget.titel, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white, fontSize: 15), + ), + ), + if (widget.subtitle != null) ...[ + const SizedBox(height: 4), + Text(widget.subtitle!, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.grey, fontSize: 12)), + ], + if (_easterEgg != null) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: MeloTheme.dunkel1, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: MeloTheme.rot.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('🥚', style: TextStyle(fontSize: 16)), + const SizedBox(width: 8), + Flexible( + child: Text( + _easterEgg!, + style: const TextStyle(color: MeloTheme.rot, fontSize: 12), + ), + ), + ], + ), + ), + ], + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 8684938..34dc967 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -97,14 +97,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" - url: "https://pub.dev" - source: hosted - version: "1.0.9" fake_async: dependency: transitive description: @@ -121,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -154,10 +154,58 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "5.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" + url: "https://pub.dev" + source: hosted + version: "10.3.1" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" + url: "https://pub.dev" + source: hosted + version: "0.3.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "06686df417f34fe9f963ed217b7fdce250e2f637ddd6c374d7eb054549770633" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" flutter_test: dependency: "direct dev" description: flutter @@ -268,10 +316,10 @@ packages: dependency: transitive description: name: lints - sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "5.1.1" logging: dependency: transitive description: @@ -661,6 +709,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + url: "https://pub.dev" + source: hosted + version: "6.3.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0d4fcc3..b020b7d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,14 +28,12 @@ dependencies: # HTTP (Navidrome Server-Sync) http: ^1.2.0 crypto: ^3.0.6 - - # UI - cupertino_icons: ^1.0.8 + flutter_secure_storage: ^10.3.1 dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^6.0.0 + flutter_lints: ^5.0.0 flutter: uses-material-design: true