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:
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user