v2.56 — UI/Design-Rework: Melo Dark Fusion (neue Startseite, 2-stufige Einstellungen, Design-System)

- Design-System: MeloDesignTokens ThemeExtension, Outfit-Font, Farben #0B0B10/#14141C/#1C1C26
- Akzentfarbe-User-Einstellung behalten (MeloTheme.akzentNotifier), Gradient aus Akzent
- Startseite: Hero-Weiterhören-Karte mit Cover, Titel, Glow-Play-Button
- 2-stufige Einstellungen: Standard + Expertenmodus (SharedPreferences Key 'experten_modus')
- Komponenten: Pill-Buttons (radius 999), Card-Radius 20, Glow-Shadow, PressScale-Widget
- Login: Gradient-Button mit Akzent-Glow
- 199 Tests grün (194 bestehend + 5 neu): ThemeExtension, Expertenmodus, Akzent, PressScale, Hero
- flutter analyze lib/ + test/ = 0 Issues
- KEIN APK
This commit is contained in:
Dustin
2026-08-06 19:56:02 +02:00
parent 85615d253c
commit 79b15a3cb8
14 changed files with 1078 additions and 364 deletions
+16 -18
View File
@@ -33,17 +33,17 @@ class MeloHeader extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShaderMask(
shaderCallback: (bounds) => const LinearGradient(
colors: [Colors.white, MeloTheme.rot],
).createShader(bounds),
child: const Text('Melo', style: TextStyle(
fontSize: 28, fontWeight: FontWeight.w700, color: Colors.white)),
),
const Text('Deine Musik. Deine Art.', style: TextStyle(fontSize: 12, color: MeloTheme.textSekundaer)),
Text('Melo', style: TextStyle(
fontSize: 28, fontWeight: FontWeight.w700, color: Colors.white,
letterSpacing: -0.5,
)),
Text('Deine Musik. Deine Art.', style: TextStyle(
fontSize: 12, color: MeloTheme.textSekundaer,
letterSpacing: 0.2,
)),
],
),
Row(children: [
@@ -69,7 +69,7 @@ class MeloHeader extends StatelessWidget {
],
),
const SizedBox(height: 8),
// Suchleiste immer sichtbar
// Suchleiste
TextField(
onSubmitted: (wert) {
if (wert.isNotEmpty) onSearch();
@@ -78,11 +78,11 @@ class MeloHeader extends StatelessWidget {
decoration: InputDecoration(
hintText: '🔍 Song, Künstler oder Tag suchen...',
hintStyle: TextStyle(color: Colors.grey.shade600, fontSize: 13),
prefixIcon: Icon(Icons.search, size: 18, color: MeloTheme.rot.withValues(alpha: 0.6)),
prefixIcon: Icon(Icons.search, size: 18, color: MeloTheme.akzent.withValues(alpha: 0.6)),
filled: true,
fillColor: MeloTheme.dunkel1,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
@@ -98,18 +98,17 @@ class MeloHeader extends StatelessWidget {
width: 40, height: 40,
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(14),
),
child: IconButton(
icon: Icon(icon, size: 18, color: MeloTheme.rot),
icon: Icon(icon, size: 18, color: MeloTheme.akzent),
onPressed: onTap,
),
);
}
}
/// Kompakter „☁️ Musikserver-Chip mit Statuspunkt.
/// Grün = verbunden, Rot = offline, pulsierend = Sync läuft.
/// Kompakter „☁️ Musikserver"-Chip mit Statuspunkt.
class _ServerStatusChip extends StatefulWidget {
final bool verbunden;
final bool syncLaeuft;
@@ -168,7 +167,7 @@ class _ServerStatusChipState extends State<_ServerStatusChip>
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Row(
@@ -184,7 +183,6 @@ class _ServerStatusChipState extends State<_ServerStatusChip>
AnimatedBuilder(
animation: _controller,
builder: (_, child) {
// Pulsierende Größe während des Syncs
final skala = widget.syncLaeuft
? 0.7 + (0.5 * _controller.value)
: 1.0;
+24 -18
View File
@@ -40,11 +40,9 @@ class _MiniPlayerState extends State<MiniPlayer> {
});
}
});
// Sofortige UI-Aktualisierung bei jedem Songwechsel
_songSub = _player.onSongWechsel.listen((_) {
if (mounted) setState(() {});
});
// Shuffle-/Repeat-/Sleep-Zustand live halten
_player.addListener(_onPlayerChanged);
}
@@ -52,8 +50,6 @@ class _MiniPlayerState extends State<MiniPlayer> {
if (mounted) setState(() {});
}
/// Öffnet den Fullscreen-Player mit Slide-up/Slide-down-Animation.
/// Die Warteschlange ist von dort per Button erreichbar.
void _oeffneNowPlaying() {
Navigator.of(context).push(
PageRouteBuilder(
@@ -94,15 +90,23 @@ class _MiniPlayerState extends State<MiniPlayer> {
final progress = _dauer.inSeconds > 0
? _position.inSeconds / _dauer.inSeconds : 0.0;
final hatCover = song.coverPfad != null && File(song.coverPfad!).existsSync();
final akzent = MeloTheme.akzent;
return GestureDetector(
onTap: _oeffneNowPlaying,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: const Color(0xFF1A0A0A),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF2A1515)),
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: MeloTheme.dunkel2),
boxShadow: [
BoxShadow(
color: akzent.withValues(alpha: 0.15),
blurRadius: 20,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -113,13 +117,17 @@ class _MiniPlayerState extends State<MiniPlayer> {
children: [
// Cover mit Fallback auf Notensymbol
ClipRRect(
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(12),
child: Container(
width: 36, height: 36,
color: MeloTheme.rot,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [akzent, Color.lerp(akzent, Colors.black, 0.5)!],
),
),
child: hatCover
? Image.file(File(song.coverPfad!), fit: BoxFit.cover)
: const Center(child: Text('', style: TextStyle(fontSize: 16))),
: const Center(child: Text('', style: TextStyle(fontSize: 16, color: Colors.white))),
),
),
const SizedBox(width: 10),
@@ -151,8 +159,8 @@ class _MiniPlayerState extends State<MiniPlayer> {
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: progress,
backgroundColor: const Color(0xFF2A2A2A),
valueColor: const AlwaysStoppedAnimation(MeloTheme.rot),
backgroundColor: MeloTheme.dunkel2,
valueColor: AlwaysStoppedAnimation(akzent),
minHeight: 2,
),
),
@@ -163,23 +171,21 @@ class _MiniPlayerState extends State<MiniPlayer> {
);
}
/// Shuffle-Icon — rot wenn aktiv
Widget _shuffleBtn() {
final aktiv = _player.zufallsmodus;
return _btn(
Icons.shuffle,
() => _player.setZufallsmodus(!aktiv),
farbe: aktiv ? MeloTheme.rot : Colors.white,
farbe: aktiv ? MeloTheme.akzent : Colors.white,
);
}
/// Repeat-Icon — 3 Zustände (aus → Titel → Playlist), Wechsel bei Tipp
Widget _repeatBtn() {
final modus = _player.wiederholmodus;
final (icon, farbe) = switch (modus) {
Wiederholmodus.aus => (Icons.repeat, Colors.white),
Wiederholmodus.titel => (Icons.repeat_one, MeloTheme.rot),
Wiederholmodus.playlist => (Icons.repeat, MeloTheme.rot),
Wiederholmodus.titel => (Icons.repeat_one, MeloTheme.akzent),
Wiederholmodus.playlist => (Icons.repeat, MeloTheme.akzent),
};
return _btn(icon, _naechsterWiederholmodus, farbe: farbe);
}
@@ -210,7 +216,7 @@ class _MiniPlayerState extends State<MiniPlayer> {
Widget _playBtn() {
return Material(
color: MeloTheme.rot,
color: MeloTheme.akzent,
borderRadius: BorderRadius.circular(50),
child: InkWell(
borderRadius: BorderRadius.circular(50),
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
/// Press-Scale Mikro-Interaktion: Button skaliert beim Drücken auf 0.97.
/// Verwendet AnimatedScale mit 120ms Curves.easeOut.
class PressScale extends StatefulWidget {
final Widget child;
final VoidCallback? onTap;
const PressScale({super.key, required this.child, this.onTap});
@override
State<PressScale> createState() => _PressScaleState();
}
class _PressScaleState extends State<PressScale> {
double _scale = 1.0;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => setState(() => _scale = 0.97),
onTapUp: (_) {
setState(() => _scale = 1.0);
widget.onTap?.call();
},
onTapCancel: () => setState(() => _scale = 1.0),
child: AnimatedScale(
scale: _scale,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
child: widget.child,
),
);
}
}
+8 -5
View File
@@ -3,8 +3,6 @@ import '../models/song.dart';
import '../utils/farb_theme.dart';
/// Zeigt die letzten abgespielten Songs als horizontale Liste.
/// Manuell in home_screen.dart einbaubar:
/// RecentWidget(songs: _vm.letzteSongs, onPlay: _vm.spieleSong)
class RecentWidget extends StatelessWidget {
final List<Song> songs;
final void Function(Song) onPlay;
@@ -35,7 +33,7 @@ class RecentWidget extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Row(
@@ -44,8 +42,13 @@ class RecentWidget extends StatelessWidget {
Container(
width: 28, height: 28,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: MeloTheme.rot.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
gradient: LinearGradient(
colors: [
MeloTheme.akzent.withValues(alpha: 0.4),
MeloTheme.akzent.withValues(alpha: 0.1),
],
),
),
child: const Center(child: Text('', style: TextStyle(fontSize: 12))),
),
+11 -4
View File
@@ -36,11 +36,18 @@ class SongTile extends StatelessWidget {
return ListTile(
contentPadding: const EdgeInsets.symmetric(vertical: 2),
leading: ClipRRect(
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(12),
child: Container(
width: 44, height: 44,
decoration: const BoxDecoration(
gradient: LinearGradient(colors: [Color(0xFF1A0000), Color(0xFF660000)]),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
MeloTheme.akzent.withValues(alpha: 0.6),
MeloTheme.akzent.withValues(alpha: 0.15),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: hatCover
? Image.file(File(song.coverPfad!), fit: BoxFit.cover)
@@ -128,7 +135,7 @@ class SongTile extends StatelessWidget {
padding: const EdgeInsets.all(6),
child: Icon(
istFavorit ? Icons.favorite : Icons.favorite_border,
size: 16, color: MeloTheme.rot,
size: 16, color: MeloTheme.akzent,
),
),
),
+5 -5
View File
@@ -20,11 +20,11 @@ class StatistikCard extends StatelessWidget {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14),
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: const Color(0xFF1A0A0A),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF2A1515)),
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
@@ -42,7 +42,7 @@ class StatistikCard extends StatelessWidget {
Widget _item(String zahl, String label) {
return Column(
children: [
Text(zahl, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: MeloTheme.rot)),
Text(zahl, style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: MeloTheme.akzent)),
Text(label, style: const TextStyle(fontSize: 11, color: MeloTheme.textSekundaer)),
],
);