## Chip-Puls (home_screen)
- ListenableBuilder hört auf Listenable.merge([_vm, _cloud, SyncService.laeuftNotifier]) — der ☁️-Musikserver-Chip pulsiert jetzt bei laufendem Sync (auch im Hintergrund/Auto-Sync), nicht nur beim Verbinden/Laden
- serverSyncLaeuft = SyncService.laeuftNotifier.value || CloudStatus.verbinde || serverLadt
## Auto-Sync-Timer app-weit (main.dart)
- _starteAutoSyncFallsNoetig() entfernt (F3a: kein Sync beim App-Start); stattdessen SyncService.starteAutoSyncTimer() — Timer tickt erst nach dem Intervall, Fälligkeit via istSyncFaellig
- shared_preferences-Import aus main.dart entfernt (Intervall-Logik lebt in SyncService/CloudScreen)
## Sync-Notification
- Sync-Kanal de.baka.melo.sync wird in main() angelegt (Android 8+: Channel nötig, sonst erscheint die Notification nicht)
- onDidReceiveNotificationResponse: Tipp auf 'sync_fertig'/'sync_fehler' → SyncService.syncBenachrichtigungGetippt++ → MeloHome öffnet den Cloud-Tab
## Version
- Versionsanzeige 2.51 → 2.52.3 (settings/home/erweitert), MeloLogger.init('2.35') → '2.52.3'
549 lines
19 KiB
Dart
549 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
import '../config/app_config.dart';
|
||
import '../utils/farb_theme.dart';
|
||
import '../services/melo_logger.dart';
|
||
import '../database/db_helper.dart';
|
||
|
||
/// „Erweitert“ – technische Bereiche, die selten gebraucht werden:
|
||
/// Logs, Diagnosedaten, Entwickler-Optionen, Scanner.
|
||
/// Erreichbar über „Mehr“-Tab und über den Einstellungen-Screen.
|
||
class ErweitertScreen extends StatefulWidget {
|
||
/// Wird aufgerufen, wenn der Nutzer „Musik scannen“ antippt.
|
||
/// Kann null sein (dann erscheint nur ein Hinweis).
|
||
final VoidCallback? onScan;
|
||
|
||
const ErweitertScreen({super.key, this.onScan});
|
||
|
||
@override
|
||
State<ErweitertScreen> createState() => _ErweitertScreenState();
|
||
}
|
||
|
||
class _ErweitertScreenState extends State<ErweitertScreen> {
|
||
bool _diagnose = AppConfig.sendeDiagnosedaten;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_ladeDiagnose();
|
||
}
|
||
|
||
Future<void> _ladeDiagnose() async {
|
||
final p = await SharedPreferences.getInstance();
|
||
if (mounted) {
|
||
setState(
|
||
() => _diagnose = p.getBool('diagnose_daten') ?? AppConfig.sendeDiagnosedaten);
|
||
}
|
||
}
|
||
|
||
@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.handyman_outlined, color: MeloTheme.rot, size: 20),
|
||
SizedBox(width: 10),
|
||
Text(
|
||
'Erweitert',
|
||
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
body: ListView(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||
children: [
|
||
_sektionHeader('Technik'),
|
||
_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),
|
||
_sektionHeader('Daten'),
|
||
Container(
|
||
decoration: BoxDecoration(
|
||
color: MeloTheme.dunkel1,
|
||
borderRadius: BorderRadius.circular(14),
|
||
border: Border.all(color: MeloTheme.dunkel2),
|
||
),
|
||
child: SwitchListTile(
|
||
title: const Text(
|
||
'Diagnosedaten senden',
|
||
style: TextStyle(color: Colors.white, fontSize: 14),
|
||
),
|
||
subtitle: const Text(
|
||
'Absturz-Logs & anonyme Nutzungsdaten',
|
||
style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 12),
|
||
),
|
||
value: _diagnose,
|
||
activeThumbColor: MeloTheme.rot,
|
||
onChanged: (v) async {
|
||
setState(() => _diagnose = v);
|
||
AppConfig.sendeDiagnosedaten = v;
|
||
final p = await SharedPreferences.getInstance();
|
||
await p.setBool('diagnose_daten', v);
|
||
},
|
||
secondary: const Icon(Icons.shield_outlined, color: MeloTheme.rot, size: 22),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
_sektionHeader('Wartung'),
|
||
_einstellungsKachel(
|
||
icon: Icons.refresh,
|
||
titel: 'Musik scannen',
|
||
untertitel: 'Lokale Ordner nach neuer Musik durchsuchen',
|
||
onTap: widget.onScan ??
|
||
() {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Scannen ist hier nicht verfügbar')),
|
||
);
|
||
},
|
||
),
|
||
const SizedBox(height: 24),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
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),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Zeigt das In-Memory-Logbuch der App (MeloLogger-Anzeige-Puffer).
|
||
class LogViewerScreen extends StatefulWidget {
|
||
const LogViewerScreen({super.key});
|
||
|
||
@override
|
||
State<LogViewerScreen> createState() => _LogViewerScreenState();
|
||
}
|
||
|
||
class _LogViewerScreenState extends State<LogViewerScreen> {
|
||
List<Map<String, dynamic>> _eintraege = [];
|
||
String _filter = 'Alle';
|
||
|
||
static const _kategorien = ['Alle', 'aktion', 'netzwerk', 'fehler', 'crash', 'performance', 'zustand', 'user'];
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_laden();
|
||
}
|
||
|
||
void _laden() {
|
||
setState(() => _eintraege = MeloLogger().anzeigeEintraege.reversed.toList());
|
||
}
|
||
|
||
String _zeit(String iso) {
|
||
final dt = DateTime.tryParse(iso);
|
||
if (dt == null) return iso;
|
||
final h = dt.hour.toString().padLeft(2, '0');
|
||
final m = dt.minute.toString().padLeft(2, '0');
|
||
final s = dt.second.toString().padLeft(2, '0');
|
||
return '$h:$m:$s';
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final gefiltert = _filter == 'Alle'
|
||
? _eintraege
|
||
: _eintraege.where((e) => e['kategorie'] == _filter).toList();
|
||
|
||
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 Text(
|
||
'Logs',
|
||
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
|
||
),
|
||
actions: [
|
||
IconButton(
|
||
icon: const Icon(Icons.refresh, color: Colors.grey, size: 20),
|
||
onPressed: _laden,
|
||
),
|
||
],
|
||
),
|
||
body: Column(
|
||
children: [
|
||
// Kategorie-Filter
|
||
SizedBox(
|
||
height: 40,
|
||
child: ListView(
|
||
scrollDirection: Axis.horizontal,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||
children: _kategorien.map((k) {
|
||
final aktiv = _filter == k;
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||
child: GestureDetector(
|
||
onTap: () => setState(() => _filter = k),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: aktiv ? MeloTheme.rot : MeloTheme.dunkel1,
|
||
borderRadius: BorderRadius.circular(14),
|
||
),
|
||
child: Text(
|
||
k,
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: aktiv ? Colors.white : MeloTheme.textSekundaer,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
const Divider(color: MeloTheme.dunkel2, height: 1),
|
||
Expanded(
|
||
child: gefiltert.isEmpty
|
||
? const Center(
|
||
child: Text(
|
||
'Noch keine Log-Einträge',
|
||
style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 13),
|
||
),
|
||
)
|
||
: ListView.builder(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
itemCount: gefiltert.length,
|
||
itemBuilder: (_, i) {
|
||
final e = gefiltert[i];
|
||
final kategorie = e['kategorie']?.toString() ?? '?';
|
||
final farbe = switch (kategorie) {
|
||
'fehler' || 'crash' => const Color(0xFFEF9A9A),
|
||
'netzwerk' => const Color(0xFFFFCC80),
|
||
'performance' => const Color(0xFF90CAF9),
|
||
_ => Colors.white70,
|
||
};
|
||
final details = (e['details'] as Map?) ?? const {};
|
||
return Container(
|
||
margin: const EdgeInsets.only(bottom: 6),
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(
|
||
color: MeloTheme.dunkel1,
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(color: MeloTheme.dunkel2),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Text(
|
||
_zeit(e['zeit']?.toString() ?? ''),
|
||
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 10),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: MeloTheme.dunkel2,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Text(
|
||
kategorie,
|
||
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 9),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
e['aktion']?.toString() ?? '',
|
||
style: TextStyle(color: farbe, fontSize: 12, fontWeight: FontWeight.w500),
|
||
),
|
||
if (details.isNotEmpty) ...[
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
details.toString(),
|
||
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 10),
|
||
maxLines: 3,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Entwickler-Screen: technische Details & Debug-Aktionen.
|
||
class EntwicklerScreen extends StatefulWidget {
|
||
const EntwicklerScreen({super.key});
|
||
|
||
@override
|
||
State<EntwicklerScreen> createState() => _EntwicklerScreenState();
|
||
}
|
||
|
||
class _EntwicklerScreenState extends State<EntwicklerScreen> {
|
||
int _songAnzahl = -1;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_ladeDaten();
|
||
}
|
||
|
||
Future<void> _ladeDaten() async {
|
||
try {
|
||
final songs = await DbHelper().alleSongs();
|
||
if (mounted) setState(() => _songAnzahl = songs.length);
|
||
} catch (_) {
|
||
if (mounted) setState(() => _songAnzahl = 0);
|
||
}
|
||
}
|
||
|
||
@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 Text(
|
||
'Entwickler',
|
||
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
|
||
),
|
||
),
|
||
body: ListView(
|
||
padding: const EdgeInsets.all(16),
|
||
children: [
|
||
_infoKarte([
|
||
('Version', 'v2.52.3'),
|
||
('Session', MeloLogger().sessionId.isEmpty ? '—' : MeloLogger().sessionId),
|
||
('Songs in DB', _songAnzahl < 0 ? '…' : '$_songAnzahl'),
|
||
('Auth-Server', AppConfig.authUrl),
|
||
('Cloud-Server', AppConfig.cloudUrl),
|
||
('Musikserver', AppConfig.navidromeUrl),
|
||
]),
|
||
const SizedBox(height: 16),
|
||
_einstellungsKachel(
|
||
icon: Icons.article_outlined,
|
||
titel: 'Logs ansehen',
|
||
untertitel: 'App-Logbuch öffnen',
|
||
onTap: () {
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(builder: (_) => const LogViewerScreen()),
|
||
);
|
||
},
|
||
),
|
||
const SizedBox(height: 8),
|
||
_einstellungsKachel(
|
||
icon: Icons.send_outlined,
|
||
titel: 'Test-Log senden',
|
||
untertitel: 'Erzeugt einen manuellen Log-Eintrag',
|
||
onTap: () {
|
||
MeloLogger().manuell('Test aus dem Entwickler-Screen');
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Test-Log erzeugt')),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _infoKarte(List<(String, String)> zeilen) {
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: MeloTheme.dunkel1,
|
||
borderRadius: BorderRadius.circular(14),
|
||
border: Border.all(color: MeloTheme.dunkel2),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
for (var i = 0; i < zeilen.length; i++) ...[
|
||
if (i > 0) const Divider(color: MeloTheme.dunkel2, height: 1),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||
child: Row(
|
||
children: [
|
||
Text(
|
||
zeilen[i].$1,
|
||
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 13),
|
||
),
|
||
const Spacer(),
|
||
Flexible(
|
||
child: Text(
|
||
zeilen[i].$2,
|
||
textAlign: TextAlign.right,
|
||
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _einstellungsKachel({
|
||
required IconData icon,
|
||
required String titel,
|
||
required String untertitel,
|
||
required VoidCallback onTap,
|
||
}) {
|
||
return 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),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|