Files
Melo/lib/shared/sub_tabs.dart
T
Hermes (Server) c2f31dee01 UX-Simulation, Teil 3: Abbrechen, Badge, Versionsnummer, Wisch-Strecke
Die vier zuletzt zurückgestellten Punkte aus der UX-Simulation:

- Abbrechen-Knopf für hängende YouTube-Downloads (Future.any gegen ein
  Abbruch-Signal, Anfrage läuft im Hintergrund aus statt die App zu
  blockieren)
- Punkt am inaktiven Download-Unterreiter, wenn dort etwas im Hintergrund
  läuft (SubTabs bekommt einen optionalen badges-Parameter)
- Versionsnummer unter "Über Melo" (neue Abhängigkeit: package_info_plus)
- Mini-Player führt beim Wischen jetzt sichtbar mit und wertet zusätzlich
  zum Schwung auch die zurückgelegte Strecke aus — ein langsames, aber
  weites Wischen wurde vorher komplett ignoriert

Dabei drei bestehende Widget-Tests korrigiert, die den Download-Tab ohne
Provider<DownloadService> aufbauten (hauptmenue_test.dart,
home_shell_test.dart, online_screen_test.dart) — lief bisher nur, weil
niemand danach fragte; das neue Badge tut das jetzt app-weit.

550 Tests grün (vorher 539), flutter analyze ohne Befund.
2026-08-25 10:25:43 +02:00

127 lines
3.9 KiB
Dart

import 'package:flutter/material.dart';
import 'theme.dart';
/// Unterreiter im Stil der Referenz-UI: der aktive Reiter ist eine helle
/// Pille, die übrigen sind nur Text.
///
/// Die Pille wechselt weich statt zu springen — so ist sichtbar, dass es
/// derselbe Reiter ist, der sich bewegt, und nicht ein neuer Bildschirm.
class SubTabs extends StatelessWidget {
const SubTabs({
super.key,
required this.labels,
required this.index,
required this.onChanged,
this.badges,
});
final List<String> labels;
final int index;
final ValueChanged<int> onChanged;
/// Punkt neben dem Reiter-Text, an derselben Position je Eintrag wie
/// [labels] — z. B. "hier läuft im Hintergrund etwas". `null` oder `false`
/// zeigt nichts.
final List<bool>? badges;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(
MeloSpace.md, MeloSpace.sm, MeloSpace.md, MeloSpace.xs),
child: Row(
children: [
for (var i = 0; i < labels.length; i++)
Padding(
padding: const EdgeInsets.only(right: MeloSpace.sm),
child: _Reiter(
label: labels[i],
aktiv: i == index,
badge: badges != null && i < badges!.length && badges![i],
onTap: () => onChanged(i),
),
),
],
),
);
}
}
class _Reiter extends StatelessWidget {
const _Reiter({
required this.label,
required this.aktiv,
required this.onTap,
this.badge = false,
});
final String label;
final bool aktiv;
final bool badge;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: aktiv,
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(MeloRadius.pill),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: AnimatedContainer(
duration: MeloMotion.ruhig(context, MeloMotion.fast),
curve: MeloMotion.curve,
// Volle 48 dp hoch: darunter trifft man den Reiter im Gehen nicht
// zuverlässig (Material 3, Mindestgröße für Bedienelemente).
constraints: const BoxConstraints(
minHeight: MeloTheme.minTouchTarget,
minWidth: MeloTheme.minTouchTarget,
),
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: MeloSpace.md),
decoration: BoxDecoration(
color: aktiv ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(MeloRadius.pill),
// Der inaktive Reiter bleibt als Fläche erkennbar, ohne sich
// vorzudrängen — sonst schweben die Wörter im Nichts.
border: aktiv ? null : MeloTheme.hairline,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedDefaultTextStyle(
duration: MeloMotion.ruhig(context, MeloMotion.fast),
curve: MeloMotion.curve,
style: TextStyle(
color: aktiv ? MeloTheme.black : MeloTheme.text2,
fontSize: 15,
height: 1.2,
fontWeight: aktiv ? FontWeight.w600 : FontWeight.w500,
),
child: Text(label),
),
if (badge) ...[
const SizedBox(width: MeloSpace.xs),
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: MeloTheme.red,
shape: BoxShape.circle,
),
),
],
],
),
),
),
),
);
}
}