mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
The setup draft and the SAF migration prompt flag were read and written directly from screens. Keys, JSON shape and defaults are unchanged.
995 lines
36 KiB
Dart
995 lines
36 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:ui' show ImageFilter;
|
|
import 'package:device_info_plus/device_info_plus.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
|
|
import 'package:flutter/cupertino.dart' show CupertinoIcons;
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
|
import 'package:spotiflac_android/constants/app_info.dart';
|
|
import 'package:spotiflac_android/screens/upgrade_intro_screen.dart';
|
|
import 'package:spotiflac_android/services/saf_migration_prompt_store.dart';
|
|
import 'package:spotiflac_android/services/upgrade_intro_service.dart';
|
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
|
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
|
import 'package:spotiflac_android/providers/playback_notification_provider.dart';
|
|
import 'package:spotiflac_android/providers/repo_provider.dart';
|
|
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
|
|
import 'package:spotiflac_android/providers/track_provider.dart';
|
|
import 'package:spotiflac_android/providers/preview_player_provider.dart';
|
|
import 'package:spotiflac_android/screens/home_tab.dart';
|
|
import 'package:spotiflac_android/screens/repo_tab.dart';
|
|
import 'package:spotiflac_android/screens/queue_tab.dart';
|
|
import 'package:spotiflac_android/screens/settings/settings_tab.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
import 'package:spotiflac_android/services/shell_navigation_service.dart';
|
|
import 'package:spotiflac_android/services/share_intent_service.dart';
|
|
import 'package:spotiflac_android/services/music_playback_deck.dart';
|
|
import 'package:spotiflac_android/services/listening_statistics.dart';
|
|
import 'package:spotiflac_android/services/discord_presence_service.dart';
|
|
import 'package:spotiflac_android/services/notification_service.dart';
|
|
import 'package:spotiflac_android/services/app_remote_config_service.dart';
|
|
import 'package:spotiflac_android/services/update_checker.dart';
|
|
import 'package:spotiflac_android/widgets/app_announcement_dialog.dart';
|
|
import 'package:spotiflac_android/widgets/app_snack_bar.dart';
|
|
import 'package:spotiflac_android/widgets/download_queue_feedback.dart';
|
|
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
|
import 'package:spotiflac_android/widgets/nav_destination_icons.dart';
|
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
|
import 'package:spotiflac_android/widgets/mini_player.dart';
|
|
import 'package:spotiflac_android/widgets/lazy_tab_view.dart';
|
|
import 'package:spotiflac_android/widgets/expressive_navigation_bar.dart';
|
|
import 'package:spotiflac_android/widgets/mornye_bottom_bar.dart';
|
|
import 'package:spotiflac_android/widgets/app_icon.dart';
|
|
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
|
|
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
|
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
|
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
|
|
import 'package:spotiflac_android/utils/logger.dart';
|
|
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
|
import 'package:spotiflac_android/utils/saf_download_tree.dart';
|
|
|
|
part 'main_shell_startup_prompts.dart';
|
|
|
|
final _log = AppLogger('MainShell');
|
|
|
|
class MainShell extends ConsumerStatefulWidget {
|
|
const MainShell({super.key});
|
|
|
|
@override
|
|
ConsumerState<MainShell> createState() => _MainShellState();
|
|
}
|
|
|
|
class _MainShellState extends ConsumerState<MainShell>
|
|
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
|
int _currentIndex = 0;
|
|
final _mornyeChrome = MornyeChromeController();
|
|
// Preserves the tab view element (and its kept-alive tabs) when the body
|
|
// structure swaps between rail and bottom-bar layouts on rotation.
|
|
final GlobalKey _pageViewKey = GlobalKey();
|
|
late final PageController _pageController;
|
|
late final AnimationController _tabJumpTransitionController;
|
|
// Transitions update their layers directly; no shell subtree rebuilds per
|
|
// animation frame.
|
|
late final CurvedAnimation _tabJumpOpacity = CurvedAnimation(
|
|
parent: _tabJumpTransitionController,
|
|
curve: Curves.easeOutCubic,
|
|
);
|
|
late final Animation<double> _tabJumpScale = Tween<double>(
|
|
begin: 0.985,
|
|
end: 1,
|
|
).animate(_tabJumpOpacity);
|
|
bool _hasCheckedUpdate = false;
|
|
bool _hasCheckedAppAnnouncement = false;
|
|
bool _initialSafRepairComplete = false;
|
|
bool _safRepairDialogVisible = false;
|
|
bool _usbVolumeWarningShown = false;
|
|
StreamSubscription<String>? _shareSubscription;
|
|
DateTime? _lastBackPress;
|
|
final GlobalKey<NavigatorState> _homeTabNavigatorKey =
|
|
ShellNavigationService.homeTabNavigatorKey;
|
|
final GlobalKey<NavigatorState> _libraryTabNavigatorKey =
|
|
ShellNavigationService.libraryTabNavigatorKey;
|
|
final GlobalKey<NavigatorState> _repoTabNavigatorKey =
|
|
ShellNavigationService.repoTabNavigatorKey;
|
|
final GlobalKey<NavigatorState> _searchTabNavigatorKey =
|
|
ShellNavigationService.searchTabNavigatorKey;
|
|
|
|
late final _PreviewStopNavigatorObserver _homePreviewStopObserver;
|
|
late final _PreviewStopNavigatorObserver _libraryPreviewStopObserver;
|
|
late final _PreviewStopNavigatorObserver _repoPreviewStopObserver;
|
|
late final _PreviewStopNavigatorObserver _searchPreviewStopObserver;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
final l10n = context.l10n;
|
|
NotificationService().updateStrings(l10n);
|
|
final playback = ref.read(musicPlayerRuntimeProvider);
|
|
playback.updateStrings(
|
|
unknownTitle: l10n.unknownTitle,
|
|
unknownArtist: l10n.unknownArtist,
|
|
);
|
|
final settings = ref.read(settingsProvider);
|
|
playback.configure(settings);
|
|
unawaited(
|
|
DiscordPresenceService.instance.setEnabled(
|
|
settings.discordRichPresenceEnabled,
|
|
),
|
|
);
|
|
listeningRecorder.setEnabled(settings.listeningStatisticsEnabled);
|
|
// Deezer & co. localize artist/genre names by IP unless told the app's
|
|
// language (issue #480).
|
|
unawaited(
|
|
PlatformBridge.setMetadataLanguage(
|
|
Localizations.localeOf(context).toLanguageTag(),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
usbAudioStatus.addListener(_onUsbAudioStatusChanged);
|
|
_homePreviewStopObserver = _PreviewStopNavigatorObserver(
|
|
() => ref.read(previewPlayerProvider.notifier).stop(),
|
|
);
|
|
_libraryPreviewStopObserver = _PreviewStopNavigatorObserver(
|
|
() => ref.read(previewPlayerProvider.notifier).stop(),
|
|
);
|
|
_repoPreviewStopObserver = _PreviewStopNavigatorObserver(
|
|
() => ref.read(previewPlayerProvider.notifier).stop(),
|
|
);
|
|
_searchPreviewStopObserver = _PreviewStopNavigatorObserver(
|
|
() => ref.read(previewPlayerProvider.notifier).stop(),
|
|
);
|
|
_pageController = PageController(initialPage: _currentIndex);
|
|
_tabJumpTransitionController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 180),
|
|
value: 1,
|
|
);
|
|
ShellNavigationService.registerTabSelectionHandler(
|
|
owner: this,
|
|
handler: _onShellTabRequested,
|
|
);
|
|
ShellNavigationService.syncState(
|
|
currentTabIndex: _currentIndex,
|
|
showRepoTab: false,
|
|
);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
await _repairSafAccessIfNeeded(
|
|
knownLost: ref.read(initialSafAccessLostProvider),
|
|
);
|
|
_initialSafRepairComplete = true;
|
|
if (!mounted) return;
|
|
unawaited(ref.read(musicPlayerRuntimeProvider).restoreSession());
|
|
await _checkUpgradeIntro();
|
|
if (!mounted) return;
|
|
_setupShareListener();
|
|
await _checkSafMigration();
|
|
final updateDialogShown = await _checkForUpdates();
|
|
if (!updateDialogShown) {
|
|
await _checkAppAnnouncement();
|
|
}
|
|
});
|
|
}
|
|
|
|
void _onUsbAudioStatusChanged() {
|
|
if (usbAudioStatus.value.reason == 'active') {
|
|
_usbVolumeWarningShown = false;
|
|
}
|
|
if (usbAudioStatus.value.reason != 'volume_unavailable' ||
|
|
_usbVolumeWarningShown) {
|
|
return;
|
|
}
|
|
_usbVolumeWarningShown = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted || usbAudioStatus.value.reason != 'volume_unavailable') {
|
|
return;
|
|
}
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(context.l10n.usbVolumeUnavailable),
|
|
duration: const Duration(seconds: 8),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
if (state == AppLifecycleState.resumed && _initialSafRepairComplete) {
|
|
unawaited(_repairSafAccessIfNeeded());
|
|
} else if (state == AppLifecycleState.paused) {
|
|
unawaited(
|
|
ref.read(musicPlayerRuntimeProvider).handler?.persistCurrentSession(),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _setupShareListener() {
|
|
final pendingUrl = ShareIntentService().consumePendingUrl();
|
|
if (pendingUrl != null) {
|
|
_handleSharedUrl(pendingUrl);
|
|
}
|
|
|
|
_shareSubscription = ShareIntentService().sharedUrlStream.listen(
|
|
(url) {
|
|
_handleSharedUrl(url);
|
|
},
|
|
onError: (Object error) {
|
|
_log.e('Share stream error: $error');
|
|
},
|
|
cancelOnError: false,
|
|
);
|
|
}
|
|
|
|
Future<void> _handleSharedUrl(String url) async {
|
|
if (!mounted) return;
|
|
|
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
|
_onShellTabRequested(ShellTab.search);
|
|
final searchNavigator = context.isMornye
|
|
? _searchTabNavigatorKey
|
|
: _homeTabNavigatorKey;
|
|
searchNavigator.currentState?.popUntil((route) => route.isFirst);
|
|
// Mount the lazy Search page before the metadata listener handles the link.
|
|
await WidgetsBinding.instance.endOfFrame;
|
|
if (!mounted) return;
|
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(context.l10n.loadingSharedLink)));
|
|
}
|
|
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
|
final trackState = ref.read(trackProvider);
|
|
if (trackState.error != null && mounted) {
|
|
final l10n = context.l10n;
|
|
final errorMsg = trackState.error!;
|
|
final isRateLimit =
|
|
errorMsg.contains('429') ||
|
|
errorMsg.toLowerCase().contains('rate limit') ||
|
|
errorMsg.toLowerCase().contains('too many requests');
|
|
final displayMessage = errorMsg == 'url_not_recognized'
|
|
? l10n.errorUrlNotRecognizedMessage
|
|
: isRateLimit
|
|
? l10n.errorRateLimitedMessage
|
|
: l10n.errorUrlFetchFailed;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(displayMessage),
|
|
// Retrying an unrecognized URL is deterministic; skip the action.
|
|
action: errorMsg == 'url_not_recognized'
|
|
? null
|
|
: SnackBarAction(
|
|
label: l10n.dialogRetry,
|
|
onPressed: () => _handleSharedUrl(url),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
usbAudioStatus.removeListener(_onUsbAudioStatusChanged);
|
|
ShellNavigationService.unregisterTabSelectionHandler(this);
|
|
_shareSubscription?.cancel();
|
|
_pageController.dispose();
|
|
_tabJumpOpacity.dispose();
|
|
_tabJumpTransitionController.dispose();
|
|
_mornyeChrome.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _resetHomeToMain() {
|
|
ref.read(previewPlayerProvider.notifier).stop();
|
|
final showStore = ref.read(
|
|
settingsProvider.select((s) => s.showExtensionStore),
|
|
);
|
|
final homeNavigator = _navigatorForTab(0, showStore);
|
|
homeNavigator?.popUntil((route) => route.isFirst);
|
|
// Unfocus BEFORE clear so _onTrackStateChanged can properly
|
|
// clear _urlController (it checks !_searchFocusNode.hasFocus)
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
if (!context.isMornye) ref.read(trackProvider.notifier).clear();
|
|
}
|
|
|
|
void _onShellTabRequested(ShellTab tab) {
|
|
if (tab == ShellTab.settings && context.isMornye) {
|
|
unawaited(_openProfileSettings());
|
|
return;
|
|
}
|
|
final showStore = ref.read(
|
|
settingsProvider.select((s) => s.showExtensionStore),
|
|
);
|
|
final index = switch (tab) {
|
|
ShellTab.home => 0,
|
|
ShellTab.search => context.isMornye ? (showStore ? 3 : 2) : 0,
|
|
ShellTab.library => 1,
|
|
ShellTab.repository => showStore ? 2 : null,
|
|
ShellTab.settings => showStore ? 3 : 2,
|
|
};
|
|
if (index != null) _onNavTap(index, resetHome: tab != ShellTab.search);
|
|
}
|
|
|
|
bool _settingsPageOpen = false;
|
|
|
|
Future<void> _openProfileSettings() async {
|
|
if (_settingsPageOpen) return;
|
|
_settingsPageOpen = true;
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
ref.read(previewPlayerProvider.notifier).stop();
|
|
try {
|
|
await Navigator.of(context).push(
|
|
slidePageRoute<void>(
|
|
page: const Scaffold(body: SettingsTab(asPage: true)),
|
|
),
|
|
);
|
|
} finally {
|
|
_settingsPageOpen = false;
|
|
}
|
|
}
|
|
|
|
void _onNavTap(int index, {bool resetHome = true}) {
|
|
final showStore = ref.read(
|
|
settingsProvider.select((s) => s.showExtensionStore),
|
|
);
|
|
_mornyeChrome.expand();
|
|
if (index == 0 && resetHome && (context.isMornye || _currentIndex == 0)) {
|
|
_resetHomeToMain();
|
|
}
|
|
if (index == 0 && _currentIndex == 0) {
|
|
return;
|
|
}
|
|
|
|
if (_currentIndex != index) {
|
|
final previousIndex = _currentIndex;
|
|
final isNonAdjacentJump = (previousIndex - index).abs() > 1;
|
|
HapticFeedback.selectionClick();
|
|
// Stop any preview snippet when leaving the current tab. (_onPageChanged
|
|
// cannot do this because _currentIndex is already updated below.)
|
|
ref.read(previewPlayerProvider.notifier).stop();
|
|
setState(() => _currentIndex = index);
|
|
ShellNavigationService.syncState(
|
|
currentTabIndex: _currentIndex,
|
|
showRepoTab: showStore,
|
|
showSearchTab: context.isMornye,
|
|
);
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
// Jump directly when skipping intermediate tabs to avoid
|
|
// sliding through them. For those jumps, keep a short fade-in
|
|
// so the transition still feels intentional.
|
|
if (context.isMornye) {
|
|
// The glass pill owns the tab transition. Sliding/fading the whole
|
|
// page at the same time continuously invalidates its live backdrop.
|
|
_tabJumpTransitionController.value = 1;
|
|
} else if (isNonAdjacentJump) {
|
|
_pageController.jumpToPage(index);
|
|
_tabJumpTransitionController.forward(from: 0);
|
|
} else {
|
|
_pageController.animateToPage(
|
|
index,
|
|
duration: const Duration(milliseconds: 250),
|
|
curve: Curves.easeOutCubic,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _onPageChanged(int index) {
|
|
_mornyeChrome.expand();
|
|
if (_currentIndex != index) {
|
|
ref.read(previewPlayerProvider.notifier).stop();
|
|
setState(() => _currentIndex = index);
|
|
final showStore = ref.read(
|
|
settingsProvider.select((s) => s.showExtensionStore),
|
|
);
|
|
ShellNavigationService.syncState(
|
|
currentTabIndex: _currentIndex,
|
|
showRepoTab: showStore,
|
|
showSearchTab: context.isMornye,
|
|
);
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
}
|
|
}
|
|
|
|
Future<void> _handleBackPress() async {
|
|
final rootNavigator = Navigator.of(context, rootNavigator: true);
|
|
final handledByRootNavigator = await rootNavigator.maybePop();
|
|
if (handledByRootNavigator) {
|
|
_lastBackPress = null;
|
|
return;
|
|
}
|
|
|
|
final showStore = ref.read(
|
|
settingsProvider.select((s) => s.showExtensionStore),
|
|
);
|
|
final currentNavigator = _navigatorForTab(_currentIndex, showStore);
|
|
final handledByCurrentNavigator =
|
|
await currentNavigator?.maybePop() ?? false;
|
|
if (handledByCurrentNavigator) {
|
|
_lastBackPress = null;
|
|
return;
|
|
}
|
|
|
|
if (!mounted) return;
|
|
|
|
final trackState = ref.read(trackProvider);
|
|
final isSearchTab =
|
|
_currentIndex == (context.isMornye ? (showStore ? 3 : 2) : 0);
|
|
|
|
final isKeyboardVisible = MediaQuery.viewInsetsOf(context).bottom > 0;
|
|
|
|
if (isSearchTab &&
|
|
trackState.isShowingRecentAccess &&
|
|
!trackState.isLoading &&
|
|
(trackState.hasSearchText || trackState.hasContent)) {
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
ref.read(previewPlayerProvider.notifier).stop();
|
|
ref.read(trackProvider.notifier).clear();
|
|
_lastBackPress = null;
|
|
return;
|
|
}
|
|
|
|
if (isSearchTab && trackState.isShowingRecentAccess) {
|
|
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
_lastBackPress = null;
|
|
return;
|
|
}
|
|
|
|
if (isSearchTab &&
|
|
!trackState.isLoading &&
|
|
(trackState.hasSearchText || trackState.hasContent)) {
|
|
// Unfocus BEFORE clear so _onTrackStateChanged can properly
|
|
// clear _urlController (it checks !_searchFocusNode.hasFocus)
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
ref.read(previewPlayerProvider.notifier).stop();
|
|
ref.read(trackProvider.notifier).clear();
|
|
_lastBackPress = null;
|
|
return;
|
|
}
|
|
|
|
if (isSearchTab && isKeyboardVisible) {
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
_lastBackPress = null;
|
|
return;
|
|
}
|
|
|
|
if (_currentIndex != 0) {
|
|
_onNavTap(0);
|
|
_lastBackPress = null;
|
|
return;
|
|
}
|
|
|
|
if (isSearchTab && trackState.isLoading) {
|
|
return;
|
|
}
|
|
|
|
final now = DateTime.now();
|
|
if (_lastBackPress != null &&
|
|
now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
|
|
unawaited(PlatformBridge.exitApp());
|
|
} else {
|
|
_lastBackPress = now;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(context.l10n.pressBackAgainToExit),
|
|
duration: const Duration(seconds: 2),
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
NavigatorState? _navigatorForTab(int index, bool showStore) {
|
|
if (index == 0) return _homeTabNavigatorKey.currentState;
|
|
if (index == 1) return _libraryTabNavigatorKey.currentState;
|
|
if (showStore && index == 2) return _repoTabNavigatorKey.currentState;
|
|
if (context.isMornye && index == (showStore ? 3 : 2)) {
|
|
return _searchTabNavigatorKey.currentState;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
ref.watch(
|
|
playbackNotificationProvider((
|
|
favorite: context.l10n.trackOptionAddToLoved,
|
|
unfavorite: context.l10n.trackOptionRemoveFromLoved,
|
|
shuffleOn: context.l10n.nowPlayingShuffleOn,
|
|
shuffleOff: context.l10n.nowPlayingPlayInOrder,
|
|
)),
|
|
);
|
|
ref.listen(settingsProvider, (_, settings) {
|
|
ref.read(musicPlayerRuntimeProvider).configure(settings);
|
|
});
|
|
ref.listen(settingsProvider.select((s) => s.discordRichPresenceEnabled), (
|
|
_,
|
|
enabled,
|
|
) {
|
|
unawaited(DiscordPresenceService.instance.setEnabled(enabled));
|
|
});
|
|
ref.listen(
|
|
settingsProvider.select((s) => s.listeningStatisticsEnabled),
|
|
(_, enabled) => listeningRecorder.setEnabled(enabled),
|
|
);
|
|
final queueState = ref.watch(
|
|
downloadQueueProvider.select((s) => s.queuedCount),
|
|
);
|
|
final showStore = ref.watch(
|
|
settingsProvider.select((s) => s.showExtensionStore),
|
|
);
|
|
final heroAnimationsEnabled = ref.watch(
|
|
settingsProvider.select((s) => s.heroAnimationsEnabled),
|
|
);
|
|
ShellNavigationService.syncState(
|
|
currentTabIndex: _currentIndex,
|
|
showRepoTab: showStore,
|
|
showSearchTab: context.isMornye,
|
|
);
|
|
final repoUpdatesCount = ref.watch(
|
|
repoProvider.select((s) => s.updatesAvailableCount),
|
|
);
|
|
|
|
final tabs = <Widget>[
|
|
_TabNavigator(
|
|
key: const ValueKey('tab-home'),
|
|
navigatorKey: _homeTabNavigatorKey,
|
|
observers: [_homePreviewStopObserver],
|
|
heroAnimationsEnabled: heroAnimationsEnabled,
|
|
child: Builder(
|
|
builder: (context) => HomeTab(
|
|
mode: context.isMornye ? HomeTabMode.browse : HomeTabMode.combined,
|
|
),
|
|
),
|
|
),
|
|
_TabNavigator(
|
|
key: const ValueKey('tab-library'),
|
|
navigatorKey: _libraryTabNavigatorKey,
|
|
observers: [_libraryPreviewStopObserver],
|
|
heroAnimationsEnabled: heroAnimationsEnabled,
|
|
child: _LibraryTabRoot(isTabActive: _currentIndex == 1),
|
|
),
|
|
if (showStore)
|
|
_TabNavigator(
|
|
key: const ValueKey('tab-repo'),
|
|
navigatorKey: _repoTabNavigatorKey,
|
|
observers: [_repoPreviewStopObserver],
|
|
heroAnimationsEnabled: heroAnimationsEnabled,
|
|
child: const RepoTab(),
|
|
),
|
|
if (!context.isMornye) const SettingsTab(),
|
|
if (context.isMornye)
|
|
_TabNavigator(
|
|
key: const ValueKey('tab-search'),
|
|
navigatorKey: _searchTabNavigatorKey,
|
|
observers: [_searchPreviewStopObserver],
|
|
heroAnimationsEnabled: heroAnimationsEnabled,
|
|
child: const HomeTab(mode: HomeTabMode.search),
|
|
),
|
|
];
|
|
|
|
final l10n = context.l10n;
|
|
final destinations = <NavigationDestination>[
|
|
NavigationDestination(
|
|
icon: AppIcon(
|
|
context.isMornye ? CupertinoIcons.house_fill : Icons.home_outlined,
|
|
),
|
|
selectedIcon: BouncingIcon(child: const AppIcon(Icons.home)),
|
|
label: l10n.navHome,
|
|
),
|
|
NavigationDestination(
|
|
icon: context.isMornye
|
|
? MornyeNavigationIcon(
|
|
icon: CupertinoIcons.square_stack_fill,
|
|
badgeCount: queueState,
|
|
)
|
|
: AnimatedBadge(
|
|
count: queueState,
|
|
child: Badge(
|
|
isLabelVisible: queueState > 0,
|
|
label: Text('$queueState'),
|
|
child: const AppIcon(Icons.library_music_outlined),
|
|
),
|
|
),
|
|
selectedIcon: SlidingIcon(
|
|
child: AnimatedBadge(
|
|
count: queueState,
|
|
child: Badge(
|
|
isLabelVisible: queueState > 0,
|
|
label: Text('$queueState'),
|
|
child: const AppIcon(Icons.library_music),
|
|
),
|
|
),
|
|
),
|
|
label: l10n.navLibrary,
|
|
),
|
|
if (showStore)
|
|
NavigationDestination(
|
|
icon: context.isMornye
|
|
? MornyeNavigationIcon(
|
|
icon: CupertinoIcons.square_grid_2x2,
|
|
badgeCount: repoUpdatesCount,
|
|
)
|
|
: AnimatedBadge(
|
|
count: repoUpdatesCount,
|
|
child: Badge(
|
|
isLabelVisible: repoUpdatesCount > 0,
|
|
label: Text('$repoUpdatesCount'),
|
|
child: const AppIcon(Icons.extension_outlined),
|
|
),
|
|
),
|
|
selectedIcon: BouncingIcon(
|
|
child: AnimatedBadge(
|
|
count: repoUpdatesCount,
|
|
child: Badge(
|
|
isLabelVisible: repoUpdatesCount > 0,
|
|
label: Text('$repoUpdatesCount'),
|
|
child: const AppIcon(Icons.extension),
|
|
),
|
|
),
|
|
),
|
|
label: l10n.navStore,
|
|
),
|
|
if (!context.isMornye)
|
|
NavigationDestination(
|
|
icon: const AppIcon(Icons.settings_outlined),
|
|
selectedIcon: SpinIcon(child: const AppIcon(Icons.settings)),
|
|
label: l10n.navSettings,
|
|
),
|
|
if (context.isMornye)
|
|
NavigationDestination(
|
|
icon: const Icon(CupertinoIcons.search),
|
|
label: l10n.mornyeSearch,
|
|
),
|
|
];
|
|
|
|
final maxIndex = tabs.length - 1;
|
|
final selectedDestination = _currentIndex.clamp(0, maxIndex);
|
|
if (_currentIndex > maxIndex) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
setState(() => _currentIndex = maxIndex);
|
|
if (_pageController.hasClients) {
|
|
_pageController.jumpToPage(maxIndex);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
final screenSize = MediaQuery.sizeOf(context);
|
|
final isTablet = screenSize.shortestSide >= 600;
|
|
// Material follows its expanded window class: landscape tablets get the
|
|
// rail, portrait tablets keep the bottom bar. Mornye keeps a bottom tab
|
|
// bar on tablets, compact and centered once the window is expanded, and
|
|
// reserves the rail for desktop-width windows.
|
|
final useNavigationRail = context.isMornye
|
|
? useNavigationRailForWidth(screenSize.width)
|
|
: useMaterialNavigationRailForWidth(screenSize.width);
|
|
final compactMornyeChrome =
|
|
context.isMornye && screenSize.width >= expandedLayoutBreakpoint;
|
|
final canMinimizeChrome =
|
|
context.isMornye &&
|
|
!useNavigationRail &&
|
|
MediaQuery.viewInsetsOf(context).bottom == 0 &&
|
|
MediaQuery.textScalerOf(context).scale(15) <= 20;
|
|
|
|
Widget observeScroll(Widget child, int index) =>
|
|
NotificationListener<ScrollNotification>(
|
|
onNotification: (notification) {
|
|
if (canMinimizeChrome && index == _currentIndex) {
|
|
return _mornyeChrome.handleScroll(notification);
|
|
}
|
|
return false;
|
|
},
|
|
child: child,
|
|
);
|
|
|
|
final pageView = KeyedSubtree(
|
|
key: _pageViewKey,
|
|
child: context.isMornye
|
|
? LazyTabView(
|
|
index: selectedDestination,
|
|
preloadKeys: {const ValueKey('tab-search')},
|
|
children: [
|
|
for (var index = 0; index < tabs.length; index++)
|
|
KeyedSubtree(
|
|
key: tabs[index].key,
|
|
child: observeScroll(tabs[index], index),
|
|
),
|
|
],
|
|
)
|
|
: FadeTransition(
|
|
opacity: _tabJumpOpacity,
|
|
child: ScaleTransition(
|
|
scale: _tabJumpScale,
|
|
child: PageView.builder(
|
|
controller: _pageController,
|
|
itemCount: tabs.length,
|
|
onPageChanged: _onPageChanged,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
// TickerMode mutes animations and lets visibility-aware widgets
|
|
// (e.g. MotionHeaderBanner) pause when their tab is hidden —
|
|
// kept-alive pages otherwise keep running offscreen.
|
|
itemBuilder: (context, index) => _KeepAliveTabPage(
|
|
key: ValueKey('page-$index'),
|
|
child: TickerMode(
|
|
enabled: index == _currentIndex,
|
|
child: observeScroll(tabs[index], index),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final shell = SelectionOverlayHost(
|
|
child: BackButtonListener(
|
|
onBackButtonPressed: () async {
|
|
await _handleBackPress();
|
|
return true;
|
|
},
|
|
child: Scaffold(
|
|
extendBody: true,
|
|
// The tab view keeps one element across the rail<->bar structure
|
|
// swap via _pageViewKey so rotation doesn't remount tab navigators.
|
|
body: useNavigationRail
|
|
? Row(
|
|
children: [
|
|
SafeArea(
|
|
right: false,
|
|
bottom: false,
|
|
child: ExpressiveNavigationRail(
|
|
selectedIndex: selectedDestination,
|
|
onDestinationSelected: _onNavTap,
|
|
backgroundColor: Theme.of(
|
|
context,
|
|
).colorScheme.surfaceContainer,
|
|
destinations: destinations,
|
|
),
|
|
),
|
|
const VerticalDivider(width: 1),
|
|
// Width-based layout (centered columns, grids) measures
|
|
// the space beside the rail rather than the whole screen.
|
|
Expanded(
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final mediaQuery = MediaQuery.of(context);
|
|
return MediaQuery(
|
|
data: mediaQuery
|
|
.copyWith(
|
|
size: Size(
|
|
constraints.maxWidth,
|
|
mediaQuery.size.height,
|
|
),
|
|
)
|
|
.removePadding(removeLeft: true),
|
|
child: pageView,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
)
|
|
: pageView,
|
|
bottomNavigationBar: Builder(
|
|
builder: (context) {
|
|
if (context.isMornye) {
|
|
return ListenableBuilder(
|
|
listenable: Listenable.merge([
|
|
ShellNavigationService.chromeBrightness,
|
|
ShellNavigationService.chromeSurface,
|
|
]),
|
|
builder: (context, child) => Theme(
|
|
data: ShellNavigationService.chromeBrightness.value == null
|
|
? Theme.of(context)
|
|
: MornyeTheme.fromContext(
|
|
context,
|
|
brightness:
|
|
ShellNavigationService.chromeBrightness.value!,
|
|
chromeSurface:
|
|
ShellNavigationService.chromeSurface.value,
|
|
),
|
|
child: child!,
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
minimum: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
|
// iPadOS-style chrome: one compact, centered pill rather
|
|
// than a bar spread across a landscape tablet.
|
|
child: Center(
|
|
heightFactor: 1,
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxWidth: compactMornyeChrome ? 600 : double.infinity,
|
|
),
|
|
child: useNavigationRail
|
|
? const MiniPlayer()
|
|
: ValueListenableBuilder<bool>(
|
|
valueListenable: _mornyeChrome,
|
|
builder: (context, collapsed, _) =>
|
|
MornyeBottomBar(
|
|
collapsed: canMinimizeChrome && collapsed,
|
|
destinations: destinations,
|
|
selectedIndex: selectedDestination,
|
|
onSelected: _onNavTap,
|
|
onHome: () => _onNavTap(0),
|
|
onSearch:
|
|
ShellNavigationService.requestSearch,
|
|
blurEnabled: ref.watch(
|
|
mornyeBlurEnabledProvider,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
final bottomBar = Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const MiniPlayer(),
|
|
if (!useNavigationRail)
|
|
DecoratedBox(
|
|
position: DecorationPosition.foreground,
|
|
decoration: BoxDecoration(
|
|
border: Border(
|
|
top: BorderSide(
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.outlineVariant.withValues(alpha: 0.5),
|
|
),
|
|
),
|
|
),
|
|
child: ExpressiveNavigationBar(
|
|
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
|
onDestinationSelected: _onNavTap,
|
|
isTablet: isTablet,
|
|
backgroundColor: settingsGroupColor(
|
|
context,
|
|
).withValues(alpha: 0.72),
|
|
destinations: destinations,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
// The backdrop blur re-filters everything scrolling underneath on
|
|
// every frame; low-end devices get an opaque base instead unless
|
|
// the user forces blur on in appearance settings.
|
|
if (!ref.watch(backdropBlurEnabledProvider)) {
|
|
return ColoredBox(
|
|
color: settingsGroupColor(context),
|
|
child: bottomBar,
|
|
);
|
|
}
|
|
return ClipRect(
|
|
child: BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
|
blendMode: BlendMode.src,
|
|
child: bottomBar,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
return DownloadQueueFeedback(child: shell);
|
|
}
|
|
}
|
|
|
|
class _TabNavigator extends StatefulWidget {
|
|
final GlobalKey<NavigatorState> navigatorKey;
|
|
final Widget child;
|
|
final List<NavigatorObserver> observers;
|
|
final bool heroAnimationsEnabled;
|
|
|
|
const _TabNavigator({
|
|
super.key,
|
|
required this.navigatorKey,
|
|
required this.child,
|
|
this.observers = const [],
|
|
required this.heroAnimationsEnabled,
|
|
});
|
|
|
|
@override
|
|
State<_TabNavigator> createState() => _TabNavigatorState();
|
|
}
|
|
|
|
class _TabNavigatorState extends State<_TabNavigator> {
|
|
late final ShellChromeObserver _chromeObserver = ShellChromeObserver(
|
|
widget.navigatorKey,
|
|
);
|
|
// Nested navigators get no HeroController from MaterialApp; without one,
|
|
// Hero widgets on routes pushed inside a tab never fly.
|
|
final HeroController _heroController =
|
|
MaterialApp.createMaterialHeroController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_chromeObserver.detach();
|
|
_heroController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Navigator(
|
|
key: widget.navigatorKey,
|
|
observers: [
|
|
_chromeObserver,
|
|
if (widget.heroAnimationsEnabled) _heroController,
|
|
...widget.observers,
|
|
],
|
|
onGenerateInitialRoutes: (_, _) => [
|
|
MaterialPageRoute<void>(builder: (_) => widget.child),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PreviewStopNavigatorObserver extends NavigatorObserver {
|
|
_PreviewStopNavigatorObserver(this._onNavigate);
|
|
|
|
final VoidCallback _onNavigate;
|
|
|
|
@override
|
|
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
|
super.didPush(route, previousRoute);
|
|
if (previousRoute != null) {
|
|
_onNavigate();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
|
super.didPop(route, previousRoute);
|
|
_onNavigate();
|
|
}
|
|
}
|
|
|
|
class _LibraryTabRoot extends ConsumerWidget {
|
|
final bool isTabActive;
|
|
|
|
const _LibraryTabRoot({required this.isTabActive});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
return QueueTab(isTabActive: isTabActive);
|
|
}
|
|
}
|
|
|
|
class _KeepAliveTabPage extends StatefulWidget {
|
|
final Widget child;
|
|
|
|
const _KeepAliveTabPage({super.key, required this.child});
|
|
|
|
@override
|
|
State<_KeepAliveTabPage> createState() => _KeepAliveTabPageState();
|
|
}
|
|
|
|
class _KeepAliveTabPageState extends State<_KeepAliveTabPage>
|
|
with AutomaticKeepAliveClientMixin<_KeepAliveTabPage> {
|
|
@override
|
|
bool get wantKeepAlive => true;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
super.build(context);
|
|
return widget.child;
|
|
}
|
|
}
|