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/settings_screen.dart
T
Dustin 22ce611537 v2.53 — Sprint E: Recap-Zeiträume (Woche/Monat/Jahr)
## Recap-Zeiträume
- recap_service.dart: RecapZeitraum-Enum (woche/monat/jahr), berechne(zeitraum:, jetzt:) mit injizierbarem Historie-Loader (Tests ohne DB), Fallback-Kette Woche → Monat → Jahr → Gesamt-Historie, Zeitraum-Getter (kopfzeile, introTitel, introUntertitel, zeitraumLabel, shareTitel, Monatsname)
- recap_screen.dart: Zeitraum-Umschalter (7 Tage / Monat / Jahr) oben im Recap, dynamische Texte („Deine Woche in Musik“, „Abspielungen in dieser Woche“, „Melo Wochen-Recap“), Seiten-Reset bei Wechsel
- home_screen.dart: Recap-Karte „Recap 2026“ + „Woche · Monat · Jahr“-Untertitel
- settings_screen.dart: Kachel „Recap (Woche/Monat/Jahr)“
- test/recap_service_test.dart: 12 neue Tests (Filter, Fallback-Kette, Aggregation, Label-Getter)
- flutter analyze 0 Issues, Tests 123/123
2026-08-05 11:10:49 +02:00

560 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 'package:shared_preferences/shared_preferences.dart';
import '../config/app_config.dart';
import '../utils/farb_theme.dart';
import '../utils/user_effekte.dart';
import '../services/auth_service.dart';
import '../services/cloud_service.dart';
import '../services/player_service.dart';
import '../services/sync_service.dart';
import '../database/db_helper.dart';
import 'cloud_screen.dart';
import 'recap_screen.dart';
import 'erweitert_screen.dart';
/// Vollwertiger Einstellungen-Screen kein Popup mehr.
/// Sprint D F4: alle Einstellungen funktionierend + persistiert
/// (Auto-Sync-Intervall, Sleep-Timer-Standard, Wiedergabegeschwindigkeit,
/// Akzentfarbe pro User, Speicher-Info).
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
// ─── Einstellungen (aus SharedPreferences geladen) ───
int _syncIntervall = 0; // 0 = Aus, sonst Stunden
int _sleepDefaultMin = 0; // 0 = Aus
double _geschwindigkeit = 1.0;
Color _akzent = MeloTheme.akzent;
Map<String, int> _speicherInfo = const {'anzahl': 0, 'bytes': 0};
static const _akzentFarben = [
Color(0xFFCC0000), // Rot (Standard)
Color(0xFF00B8A9), // Türkis
Color(0xFF1E6FD9), // Blau
Color(0xFF2E9E4F), // Grün
Color(0xFF8E44AD), // Lila
Color(0xFFE67E22), // Orange
Color(0xFFD81B60), // Pink
];
@override
void initState() {
super.initState();
_ladeEinstellungen();
_ladeSpeicherInfo();
}
Future<void> _ladeEinstellungen() async {
final p = await SharedPreferences.getInstance();
final speed = p.getDouble('playback_speed') ?? 1.0;
final akzentHex =
p.getString(UserEffekt.akzentKey(AuthService().benutzer));
if (!mounted) return;
setState(() {
_syncIntervall = p.getInt('cloud_interval') ?? 0;
_sleepDefaultMin = p.getInt('sleep_timer_default_min') ?? 0;
_geschwindigkeit = speed;
_akzent = UserEffekt.farbeAusHex(akzentHex) ?? MeloTheme.akzent;
});
// Speed-Default auch auf den Player anwenden (gilt fürs Abspielen)
await PlayerService().setGeschwindigkeit(speed);
}
Future<void> _ladeSpeicherInfo() async {
final info = await DbHelper().speicherInfo();
if (!mounted) return;
setState(() => _speicherInfo = info);
}
// ─── Setter (persistieren + anwenden) ───
Future<void> _intervallSetzen(int stunden) async {
setState(() => _syncIntervall = stunden);
final p = await SharedPreferences.getInstance();
await p.setInt('cloud_interval', stunden);
await p.setBool('cloud_auto', stunden > 0); // Kompatibilität (altes Flag)
await SyncService.starteAutoSyncTimer(); // app-weiter Timer neu starten
}
Future<void> _sleepDefaultSetzen(int min) async {
setState(() => _sleepDefaultMin = min);
final p = await SharedPreferences.getInstance();
await p.setInt('sleep_timer_default_min', min);
}
Future<void> _geschwindigkeitSetzen(double wert) async {
setState(() => _geschwindigkeit = wert);
final p = await SharedPreferences.getInstance();
await p.setDouble('playback_speed', wert);
await PlayerService().setGeschwindigkeit(wert);
}
Future<void> _akzentSetzen(Color farbe) async {
setState(() => _akzent = farbe);
MeloTheme.akzent = farbe; // live — main.dart rebuildet das Theme
final p = await SharedPreferences.getInstance();
await p.setString(
UserEffekt.akzentKey(AuthService().benutzer), _hexVonFarbe(farbe));
}
String _hexVonFarbe(Color c) {
String hx(int w) => w.toRadixString(16).padLeft(2, '0').toUpperCase();
return '${hx((c.r * 255).round())}${hx((c.g * 255).round())}${hx((c.b * 255).round())}';
}
String get _speicherText {
final anzahl = _speicherInfo['anzahl'] ?? 0;
final bytes = _speicherInfo['bytes'] ?? 0;
if (bytes >= 1073741824) {
return '$anzahl Songs · ${(bytes / 1073741824).toStringAsFixed(2)} GB';
}
return '$anzahl Songs · ${(bytes / 1048576).toStringAsFixed(1)} MB';
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: MeloTheme.schwarz,
appBar: AppBar(
backgroundColor: MeloTheme.dunkel1,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white, size: 22),
onPressed: () => Navigator.pop(context),
),
title: const Row(
children: [
Icon(Icons.settings, color: MeloTheme.rot, size: 20),
SizedBox(width: 10),
Text(
'Einstellungen',
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
),
],
),
),
body: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: [
// ─── Sektion: Verbindung ───
_sektionHeader('Verbindung'),
_einstellungsKachel(
icon: Icons.cloud_outlined,
titel: 'Cloud Sync',
untertitel: 'Auto-Sync, Upload & Download',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CloudScreen(cloud: CloudService()),
),
);
},
),
_einstellungsKachel(
icon: Icons.dns_outlined,
titel: 'Musikserver',
untertitel: 'Navidrome Musik-Server verbinden',
onTap: () {
// Signal zum Öffnen des Server-Browsers
Navigator.pop(context, 'server');
},
),
const SizedBox(height: 8),
// ─── Sektion: Wiedergabe & Sync ───
_sektionHeader('Wiedergabe & Sync'),
_einstellungsGruppe([
_gruppenZeile(
label: 'Auto-Sync-Intervall',
wert: _syncIntervall == 0 ? 'Aus' : '${_syncIntervall}h',
inhalt: Row(children: [
_wahlChip('Aus', _syncIntervall == 0, () => _intervallSetzen(0)),
_wahlChip('1h', _syncIntervall == 1, () => _intervallSetzen(1)),
_wahlChip('3h', _syncIntervall == 3, () => _intervallSetzen(3)),
_wahlChip('6h', _syncIntervall == 6, () => _intervallSetzen(6)),
_wahlChip('12h', _syncIntervall == 12, () => _intervallSetzen(12)),
]),
),
const Divider(color: MeloTheme.dunkel2, height: 1),
_gruppenZeile(
label: 'Sleep-Timer-Standard',
wert: _sleepDefaultMin == 0 ? 'Aus' : '$_sleepDefaultMin Min',
inhalt: Row(children: [
_wahlChip('Aus', _sleepDefaultMin == 0, () => _sleepDefaultSetzen(0)),
_wahlChip('15', _sleepDefaultMin == 15, () => _sleepDefaultSetzen(15)),
_wahlChip('30', _sleepDefaultMin == 30, () => _sleepDefaultSetzen(30)),
_wahlChip('45', _sleepDefaultMin == 45, () => _sleepDefaultSetzen(45)),
_wahlChip('60', _sleepDefaultMin == 60, () => _sleepDefaultSetzen(60)),
]),
),
const Divider(color: MeloTheme.dunkel2, height: 1),
_gruppenZeile(
label: 'Wiedergabegeschwindigkeit',
wert: '${_geschwindigkeit.toStringAsFixed(2)}x',
inhalt: Slider(
value: _geschwindigkeit,
min: 0.5,
max: 2.0,
divisions: 6,
activeColor: MeloTheme.akzent,
// Live-Vorschau beim Ziehen, persistieren erst am Ende
onChanged: (v) => setState(() => _geschwindigkeit = v),
onChangeEnd: _geschwindigkeitSetzen,
),
),
]),
const SizedBox(height: 8),
// ─── Sektion: Aussehen ───
_sektionHeader('Aussehen'),
_einstellungsGruppe([
_gruppenZeile(
label: 'Akzentfarbe',
wert: 'Für ${AuthService().benutzer}',
inhalt: Row(children: [
for (final farbe in _akzentFarben) ...[
_akzentPunkt(farbe, _akzent == farbe, () => _akzentSetzen(farbe)),
],
]),
),
]),
const SizedBox(height: 8),
// ─── Sektion: Daten & Privatsphäre ───
_sektionHeader('Daten & Privatsphäre'),
_infoKachel(
icon: Icons.storage_outlined,
titel: 'Lokale Musik',
wert: _speicherText,
),
_einstellungsKachel(
icon: Icons.auto_graph,
titel: 'Recap (Woche/Monat/Jahr)',
untertitel: 'Deine Hörstatistiken wie Spotify Wrapped',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const RecapScreen()),
);
},
),
const SizedBox(height: 8),
// ─── Sektion: Erweitert (Technik) ───
_sektionHeader('Erweitert'),
_einstellungsKachel(
icon: Icons.handyman_outlined,
titel: 'Erweitert',
untertitel: 'Logs, Diagnose, Entwickler & Scanner',
onTap: () async {
// Erweitert-Screen öffnen. „Musik scannen“ (onScan) schließt den
// Erweitert-Screen mit dem Ergebnis 'scanner' → hier awaiten und
// das Ergebnis an MeloHome weiterreichen, das den Scanner startet.
final result = await Navigator.push<String>(
context,
MaterialPageRoute(
builder: (_) => ErweitertScreen(
onScan: () {
Navigator.pop(context, 'scanner');
},
),
),
);
if (!mounted || result != 'scanner') return;
Navigator.pop(this.context, 'scanner');
},
),
_einstellungsKachel(
icon: Icons.article_outlined,
titel: 'Logs',
untertitel: 'App-Logbuch im Speicher ansehen',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const LogViewerScreen()),
);
},
),
_einstellungsKachel(
icon: Icons.bug_report_outlined,
titel: 'Entwickler',
untertitel: 'Version, URLs & technische Details',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const EntwicklerScreen()),
);
},
),
const SizedBox(height: 8),
// ─── Sektion: Info ───
_sektionHeader('Info'),
Container(
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Column(
children: [
_infoZeile('Version', '2.52.3'),
const Divider(color: MeloTheme.dunkel2, height: 1),
_infoZeile('Theme', 'Schwarz + Akzent'),
const Divider(color: MeloTheme.dunkel2, height: 1),
_infoZeile('Auth', AppConfig.authUrl),
const Divider(color: MeloTheme.dunkel2, height: 1),
_infoZeile('Cloud', AppConfig.cloudUrl),
],
),
),
const SizedBox(height: 24),
// ─── Abmelden ───
SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton.icon(
onPressed: () {
Navigator.pop(context, 'logout');
},
icon: const Icon(Icons.logout, size: 18),
label: const Text('Abmelden', style: TextStyle(fontSize: 14)),
style: OutlinedButton.styleFrom(
foregroundColor: MeloTheme.rot,
side: const BorderSide(color: MeloTheme.rot),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
),
),
const SizedBox(height: 32),
],
),
);
}
Widget _sektionHeader(String titel) {
return Padding(
padding: const EdgeInsets.fromLTRB(4, 16, 4, 8),
child: Text(
titel,
style: const TextStyle(
color: MeloTheme.textSekundaer,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 1.2,
),
),
);
}
Widget _einstellungsKachel({
required IconData icon,
required String titel,
required String untertitel,
required VoidCallback onTap,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: MeloTheme.dunkel2,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: MeloTheme.rot, size: 20),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
titel,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
),
const SizedBox(height: 2),
Text(
untertitel,
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 12),
),
],
),
),
const Icon(Icons.chevron_right, color: MeloTheme.textSekundaer, size: 20),
],
),
),
),
),
);
}
/// Informations-Kachel (nicht antippbar) — z.B. Speicher-Info.
Widget _infoKachel({
required IconData icon,
required String titel,
required String wert,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: MeloTheme.dunkel2,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: MeloTheme.rot, size: 20),
),
const SizedBox(width: 14),
Expanded(
child: Text(
titel,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
),
),
Text(
wert,
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 12),
),
],
),
),
);
}
/// Gruppe aus mehreren Einstellungs-Zeilen in einer abgerundeten Box.
Widget _einstellungsGruppe(List<Widget> kinder) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Column(children: kinder),
);
}
Widget _gruppenZeile({
required String label,
String? wert,
required Widget inhalt,
}) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
),
),
if (wert != null)
Text(
wert,
style: TextStyle(color: MeloTheme.akzent, fontSize: 13, fontWeight: FontWeight.w600),
),
],
),
const SizedBox(height: 10),
inhalt,
],
),
);
}
Widget _wahlChip(String label, bool aktiv, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(
color: aktiv ? MeloTheme.akzent : MeloTheme.dunkel2,
borderRadius: BorderRadius.circular(16),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
color: aktiv ? Colors.white : MeloTheme.textSekundaer,
),
),
),
);
}
Widget _akzentPunkt(Color farbe, bool aktiv, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(right: 10),
width: 34,
height: 34,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: farbe,
border: Border.all(
color: aktiv ? Colors.white : Colors.transparent,
width: 2.5,
),
),
child: aktiv ? const Icon(Icons.check, size: 18, color: Colors.white) : null,
),
);
}
Widget _infoZeile(String label, String wert) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Text(
label,
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 13),
),
const Spacer(),
Text(
wert,
style: const TextStyle(color: Colors.white70, fontSize: 13),
),
],
),
);
}
}