Zurück-Geste auf Nicht-Start-Tabs navigiert zu Meine Musik statt die App zu minimieren

Tab-Wechsel in HomeShell laufen nur über setState (IndexedStack +
BottomNavigationBar), nie über Navigator.push — eine System-Zurück-Geste
hatte auf Nicht-Start-Tabs also nie eine Route zum Poppen und minimierte
stattdessen die App. build() jetzt in ein PopScope gewickelt, das bei
_index != 0 zuerst zu Tab 0 zurückspringt.
This commit is contained in:
Hermes (Server)
2026-08-27 15:31:21 +02:00
parent 1b096f5227
commit 90b63c467e
3 changed files with 123 additions and 32 deletions
+15
View File
@@ -5,6 +5,21 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased] ## [Unreleased]
### ⬅️ Zurück-Geste auf Nicht-Start-Tabs minimierte die App (2026-08-27)
- 🐛 **Auf "Favoriten" (oder einem anderen Tab außer "Meine Musik") tippen und
dann die System-Zurück-Geste nutzen minimierte die App**, statt zur
vorherigen Ansicht zu navigieren. Grund: Tab-Wechsel in `HomeShell`
laufen nur über `setState` (`IndexedStack` + `BottomNavigationBar`), nie
über `Navigator.push` — es lag also nie eine Route auf dem
Navigator-Stack, die eine Zurück-Geste hätte poppen können.
- 🔧 **Fix:** `build()` in `main.dart` in ein `PopScope` gewickelt
(`canPop: _index == 0`), das bei einem Nicht-Start-Tab zuerst zu Tab 0
("Meine Musik") zurückspringt, statt die App zu verlassen.
-**Getestet:** neuer Widget-Test in `home_shell_test.dart` (RED→GREEN,
simuliert die System-Zurück-Geste über `WidgetsApp.didPopRoute()`), volle
Suite (593 Tests) grün, `flutter analyze` ohne Befund.
### 🔄 Doppelten Scan-Button auf Android entfernt (2026-08-27) ### 🔄 Doppelten Scan-Button auf Android entfernt (2026-08-27)
- 🐛 **In den Einstellungen gab es auf Android zwei Buttons für dieselbe - 🐛 **In den Einstellungen gab es auf Android zwei Buttons für dieselbe
+41 -32
View File
@@ -222,39 +222,48 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
const FavoritesScreen(), const FavoritesScreen(),
const YoutubeSearchScreen(), const YoutubeSearchScreen(),
]; ];
return Scaffold( return PopScope(
body: Column( // Tab-Wechsel laufen nur über setState, nie über Navigator.push — die
children: [ // Zurück-Geste auf einem Nicht-Start-Tab hätte sonst nichts zum
Expanded(child: IndexedStack(index: _index, children: tabs)), // Poppen und würde die App minimieren statt zu "Meine Musik" zu gehen.
const MiniPlayer(), canPop: _index == 0,
], onPopInvokedWithResult: (didPop, _) {
), if (!didPop) setState(() => _index = 0);
// Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund },
// optisch im Inhalt auf und wirkt nicht wie ein Hauptmenü. child: Scaffold(
bottomNavigationBar: DecoratedBox( body: Column(
decoration: const BoxDecoration( children: [
border: Border(top: BorderSide(color: MeloTheme.border)), Expanded(child: IndexedStack(index: _index, children: tabs)),
const MiniPlayer(),
],
), ),
child: BottomNavigationBar( // Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund
currentIndex: _index, // optisch im Inhalt auf und wirkt nicht wie ein Hauptmenü.
onTap: _goTo, bottomNavigationBar: DecoratedBox(
items: const [ decoration: const BoxDecoration(
BottomNavigationBarItem( border: Border(top: BorderSide(color: MeloTheme.border)),
icon: Icon(Icons.headphones), label: 'Meine Musik'), ),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suchen'), child: BottomNavigationBar(
BottomNavigationBarItem( currentIndex: _index,
icon: Icon(Icons.download_outlined), onTap: _goTo,
activeIcon: Icon(Icons.download), items: const [
label: 'Download'), BottomNavigationBarItem(
BottomNavigationBarItem( icon: Icon(Icons.headphones), label: 'Meine Musik'),
icon: Icon(Icons.favorite_border), BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suchen'),
activeIcon: Icon(Icons.favorite), BottomNavigationBarItem(
label: 'Favoriten'), icon: Icon(Icons.download_outlined),
BottomNavigationBarItem( activeIcon: Icon(Icons.download),
icon: Icon(Icons.smart_display_outlined), label: 'Download'),
activeIcon: Icon(Icons.smart_display), BottomNavigationBarItem(
label: 'YT-Suche'), icon: Icon(Icons.favorite_border),
], activeIcon: Icon(Icons.favorite),
label: 'Favoriten'),
BottomNavigationBarItem(
icon: Icon(Icons.smart_display_outlined),
activeIcon: Icon(Icons.smart_display),
label: 'YT-Suche'),
],
),
), ),
), ),
); );
+67
View File
@@ -72,4 +72,71 @@ void main() {
handler.dispose(); handler.dispose();
await db.close(); await db.close();
}); });
testWidgets('Zurück-Geste auf einem Nicht-Start-Tab wechselt zu '
'"Meine Musik" statt die App zu minimieren', (tester) async {
final db = MeloDb(NativeDatabase.memory());
final lib = LibraryService(db);
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
final auth = BakaAuth(speicher: _MemorySpeicher());
await tester.pumpWidget(MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<LibraryService>.value(value: lib),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)),
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
ChangeNotifierProvider<OfflineMode>(create: (_) => OfflineMode()),
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
ChangeNotifierProvider<YtDownloadService>(
create: (_) => YtDownloadService(auth: auth)),
ChangeNotifierProvider<YtSearchService>(
create: (_) => YtSearchService(auth: auth)),
ChangeNotifierProvider<DownloadService>(
create: (_) => DownloadService(db: db, navidrome: NavidromeService())),
ChangeNotifierProvider<SyncService>(
create: (_) => SyncService(
db: db, cloud: MeloCloudService(auth: auth))),
],
child: const MaterialApp(home: HomeShell()),
));
await tester.pump();
// Auf "Favoriten" wechseln (Index 3) — kein Navigator.push, nur der Tab.
await tester.tap(find.descendant(
of: find.byType(BottomNavigationBar), matching: find.text('Favoriten')));
await tester.pump();
expect(
tester
.widget<BottomNavigationBar>(find.byType(BottomNavigationBar))
.currentIndex,
3);
// System-Zurück-Geste simulieren — derselbe Weg, über den auch das
// echte Android-Zurück-Gesten-Signal bei WidgetsApp ankommt.
final widgetsAppState = tester.state(find.byType(WidgetsApp));
// ignore: avoid_dynamic_calls
await (widgetsAppState as dynamic).didPopRoute();
await tester.pump();
// Ohne Abfangen bliebe der Tab unverändert (Flutter minimiert dann die
// App statt zu navigieren) — die Geste muss stattdessen zu Tab 0 führen.
expect(
tester
.widget<BottomNavigationBar>(find.byType(BottomNavigationBar))
.currentIndex,
0);
// Baum abbauen, bevor der Test endet: der Positions-Timer des Audio-
// Handlers läuft sonst über das Testende hinaus weiter, und der
// Test-Rahmen meldet einen offenen Timer (wie in hauptmenue_test.dart
// dokumentiert). Aus demselben Grund bewusst kein db.close() nach
// einem tester.tap.
await tester.pumpWidget(const SizedBox());
handler.dispose();
await tester.pump(const Duration(milliseconds: 1));
});
} }