This repository has been archived on 2026-08-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
melo-app/lib/screens/home_screen.dart
T
Dustin d555a7f146 v2.36 — Sprint 1: Behebe Issues #1 (Zurück-Button), #5 (Ausgeloggt), #4 (Berechtigungen)
Issue #1: CloudScreen Zurück-Button — onZurueck Callback statt Navigator.pop()
- CloudScreen erhält VoidCallback? onZurueck Parameter
- Bei onZurueck != null → Callback (Tab-Wechsel in home_screen)
- Fallback: Navigator.pop(context) für Standalone-Nutzung

Issue #5: Token wird nur bei HTTP 401 gelöscht
- _tokenOnlinePruefen(): nur statusCode == 401 → false
- 5xx, andere Fehler, Netzwerkfehler → true (Token behalten)
- Verhindert Ausloggen bei temporären Server-Problemen

Issue #4: Android 13+ Berechtigungen beim App-Start
- main.dart: frageSpeicherZugriff() + POST_NOTIFICATIONS nach Auth-Init
- musik_scanner.dart: Permission-Check vor externen Ordnern in _sammleMusikPfade()

Build: ⏭️ (no APK)
2026-08-02 15:48:08 +02:00

508 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'dart:async';
import '../viewmodels/melo_home_viewmodel.dart';
import '../models/song.dart';
import '../models/playlist.dart';
import '../utils/farb_theme.dart';
import '../widgets/mini_player.dart';
import '../widgets/melo_header.dart';
import '../widgets/statistik_card.dart';
import '../widgets/tag_leiste.dart';
import '../widgets/song_tile.dart';
import '../widgets/playlist_sheet.dart';
import 'download_screen.dart';
import 'cloud_screen.dart';
import 'settings_screen.dart';
import '../services/cloud_service.dart';
import '../services/auth_service.dart';
import '../config/app_config.dart';
class MeloHome extends StatefulWidget {
const MeloHome({super.key});
@override
State<MeloHome> createState() => _MeloHomeState();
}
class _MeloHomeState extends State<MeloHome> {
final MeloHomeViewModel _vm = MeloHomeViewModel();
final CloudService _cloud = CloudService();
int _aktiverTab = 0;
@override
void initState() {
super.initState();
_vm.ladeSongs();
}
@override
void dispose() {
_vm.dispose();
super.dispose();
}
Future<void> _zeigeSuche() async {
final controller = TextEditingController();
String modus = 'Alle';
final ergebnis = await showDialog<String>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Text('🔍 Song suchen', style: TextStyle(color: Colors.white, fontSize: 18)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: controller,
autofocus: true,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
hintText: 'Titel, Künstler oder Tag...',
hintStyle: TextStyle(color: Colors.grey),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Row(
children: [
_suchChip('Alle', modus == 'Alle', () => setDialogState(() => modus = 'Alle')),
const SizedBox(width: 6),
_suchChip('Titel', modus == 'Titel', () => setDialogState(() => modus = 'Titel')),
const SizedBox(width: 6),
_suchChip('Künstler', modus == 'Künstler', () => setDialogState(() => modus = 'Künstler')),
const SizedBox(width: 6),
_suchChip('Tag', modus == 'Tag', () => setDialogState(() => modus = 'Tag')),
],
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')),
TextButton(
onPressed: () => Navigator.pop(ctx, '${controller.text}|$modus'),
child: const Text('Suchen', style: TextStyle(color: MeloTheme.rot)),
),
],
),
),
);
if (ergebnis == null || ergebnis.isEmpty) return;
final teile = ergebnis.split('|');
final suchtext = teile[0].toLowerCase();
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 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 s.tagIds != null && matchingTags.any((name) {
final tag = _vm.tagsMap[name];
return tag != null && s.tagIds!.contains(tag.id);
});
}).toList();
if (!mounted) return;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: Text('🔍 ${gefiltert.length} Treffer', style: const TextStyle(color: Colors.white)),
content: SizedBox(
width: double.maxFinite,
height: 300,
child: gefiltert.isEmpty
? const Center(child: Text('Keine Treffer', style: TextStyle(color: Colors.grey)))
: ListView.builder(
itemCount: gefiltert.length,
itemBuilder: (_, i) => ListTile(
leading: Container(
width: 36, height: 36,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
gradient: const LinearGradient(colors: [Color(0xFF1A0000), Color(0xFF660000)]),
),
child: const Center(child: Text('♪', style: TextStyle(fontSize: 14, color: Colors.white54))),
),
title: Text(gefiltert[i].titel, style: const TextStyle(color: Colors.white)),
subtitle: Text(gefiltert[i].kuenstler, style: const TextStyle(color: Colors.grey)),
onTap: () { Navigator.pop(ctx); _vm.spieleSong(gefiltert[i]); },
),
),
),
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Schließen'))],
),
);
}
Future<void> _scanMusik() async {
final erlaubt = await _vm.scanner.frageSpeicherZugriff();
if (!erlaubt) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Bitte Speicherzugriff erlauben')),
);
}
return;
}
await _vm.scanner.scanneMusikOrdner();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Scannen fertig: ${_vm.scanner.anzahlNeueSongs} neue Songs gefunden')),
);
await _vm.ladeSongs();
}
}
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: PlaylistSheet(vm: _vm),
),
);
}
void _zeigeAddToPlaylist(Song song) async {
final playlists = await _vm.playlists.allePlaylists();
if (!mounted || playlists.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Erst eine Playlist erstellen')),
);
}
return;
}
final auswahl = await showDialog<Playlist>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Text('Zu Playlist hinzufügen', style: TextStyle(color: Colors.white, fontSize: 16)),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: playlists.length,
itemBuilder: (_, i) => ListTile(
leading: const Icon(Icons.queue_music, color: MeloTheme.rot, size: 18),
title: Text(playlists[i].name, style: const TextStyle(color: Colors.white, fontSize: 14)),
subtitle: Text('${playlists[i].songCount} Songs', style: const TextStyle(fontSize: 11, color: MeloTheme.textSekundaer)),
onTap: () => Navigator.pop(ctx, playlists[i]),
),
),
),
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen'))],
),
);
if (auswahl != null && song.id != null) {
await _vm.playlists.songHinzufuegen(auswahl.id!, song.id!);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('→ ${auswahl.name}')),
);
}
}
}
Future<void> _zeigeDownloadDialog() async {
// Statt Dialog → zum Download-Tab wechseln
setState(() => _aktiverTab = 1);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _vm,
builder: (_, _) {
if (_vm.ladt) {
return const Scaffold(
backgroundColor: MeloTheme.schwarz,
body: Center(child: CircularProgressIndicator(color: MeloTheme.rot)),
);
}
final gesamtMB = _vm.songs.isEmpty ? '0'
: (_vm.songs.fold(0, (int s, Song song) => s + song.groesseBytes) / 1048576).toStringAsFixed(0);
final gesamtMin = _vm.songs.isEmpty ? 0
: (_vm.songs.fold(0, (int s, Song song) => s + song.dauerSekunden) / 60).round();
return Scaffold(
backgroundColor: MeloTheme.schwarz,
body: SafeArea(
child: _aktiverTab == 1
? DownloadScreen(
downloader: _vm.downloader,
onSongsChanged: _vm.ladeSongs,
)
: _aktiverTab == 4
? CloudScreen(
cloud: _cloud,
onZurueck: () => setState(() => _aktiverTab = 0),
)
: 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:
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),
Expanded(child: _songListe()),
const MiniPlayer(),
const SizedBox(height: 8),
],
),
),
bottomNavigationBar: _bottomNav(),
);
},
);
}
Widget _songListe() {
final songs = _vm.gefilterteSongs;
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('📂 Alle Songs', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white)),
Row(children: [
Text('${songs.length} Titel${_vm.aktiveTags.isNotEmpty ? ' (gefiltert)' : ''}', style: TextStyle(fontSize: 12, color: MeloTheme.rot)),
const SizedBox(width: 8),
GestureDetector(
onTap: _scanMusik,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
border: Border.all(color: MeloTheme.dunkel2),
borderRadius: BorderRadius.circular(8),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.refresh, size: 12, color: MeloTheme.rot),
SizedBox(width: 4),
Text('Scannen', style: TextStyle(fontSize: 11, color: MeloTheme.rot)),
],
),
),
),
]),
],
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 4),
itemCount: songs.length,
itemBuilder: (_, i) => SongTile(
song: songs[i],
istFavorit: songs[i].id != null && _vm.favoritenIds.contains(songs[i].id),
onFavoriteToggle: _vm.favoritenUmschalten,
onPlay: _vm.spieleSong,
onMetadataChanged: _vm.ladeSongs,
onAddToPlaylist: _zeigeAddToPlaylist,
),
),
),
],
);
}
Future<void> _oeffneEinstellungen() async {
final result = await Navigator.push<String>(
context,
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
if (!mounted || result == null) return;
switch (result) {
case 'server':
_zeigeServerBrowser();
break;
case 'logout':
await AuthService().logout();
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const MeloHome()),
);
}
break;
}
}
Widget _bottomNav() {
return Container(
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: MeloTheme.dunkel1)),
),
child: BottomNavigationBar(
type: BottomNavigationBarType.fixed,
backgroundColor: MeloTheme.schwarz,
selectedItemColor: MeloTheme.rot,
unselectedItemColor: MeloTheme.textSekundaer,
currentIndex: _aktiverTab,
onTap: (i) {
if (i == 5) {
// Einstellungen als Screen öffnen
_oeffneEinstellungen();
return;
}
setState(() => _aktiverTab = i);
// Zurück zu Musik → Filter zurücksetzen wenn Favoriten aktiv
if (i == 0 && _vm.aktiveTags.contains('★ Favoriten')) {
_vm.aktiveTags.clear();
} else if (i == 3) {
_vm.aktiveTags = {'★ Favoriten'};
}
},
items: const [
BottomNavigationBarItem(icon: Icon(Icons.music_note, size: 22), label: 'Musik'),
BottomNavigationBarItem(icon: Icon(Icons.add_circle, size: 22), label: 'Lied +'),
BottomNavigationBarItem(icon: Icon(Icons.label, size: 22), label: 'Tags'),
BottomNavigationBarItem(icon: Icon(Icons.favorite, size: 22), label: 'Favoriten'),
BottomNavigationBarItem(icon: Icon(Icons.cloud, size: 22), label: 'Cloud'),
BottomNavigationBarItem(icon: Icon(Icons.settings, size: 22), label: 'Einstellungen'),
],
),
);
}
/// Hilfs-Widget für Such-Modus-Chips
Widget _suchChip(String label, bool aktiv, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: aktiv ? MeloTheme.rot : MeloTheme.dunkel2,
borderRadius: BorderRadius.circular(12),
),
child: Text(label, style: TextStyle(fontSize: 11, color: aktiv ? Colors.white : MeloTheme.textSekundaer)),
),
);
}
/// Banner "💌 Seit 2008" erscheint nach 10 Playbacks
Widget _botschaftBanner() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0xFF2A0000), Color(0xFF1A0000)]),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: MeloTheme.rot.withValues(alpha: 0.4)),
),
child: Row(
children: [
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)),
],
),
),
GestureDetector(
onTap: _vm.botschaftAusblenden,
child: const Icon(Icons.close, size: 16, color: MeloTheme.textSekundaer),
),
],
),
),
);
}
}