Files
SpotiFLAC-Mobile/lib/app.dart
T
zarzet 2ab4aaa8dc fix(ui): allow landscape only in the full player
Keep application pages in portrait and let player sheets inherit rotation from the full player. Restore portrait on navigation, replacement, removal, and completed dismissal; discard stale minimization geometry when the screen rotates on close.

Validated with 12 Flutter tests and targeted analysis. iOS simulator build confirms portrait startup from a landscape device. Platform orientation restrictions still apply on large Android displays and multitasking iPads.
2026-09-25 18:05:40 +07:00

243 lines
8.3 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:go_router/go_router.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/screens/main_shell.dart';
import 'package:spotiflac_android/screens/setup_screen.dart';
import 'package:spotiflac_android/screens/tutorial_screen.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/app_navigation_service.dart';
import 'package:spotiflac_android/services/app_orientation.dart';
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
import 'package:spotiflac_android/l10n/supported_locales.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
String initialLocationForAppState({
required bool isFirstLaunch,
required bool hasCompletedTutorial,
}) {
if (isFirstLaunch) return '/setup';
if (!hasCompletedTutorial) return '/tutorial';
return '/';
}
final _routerProvider = Provider<GoRouter>((ref) {
final routingSettings = ref.watch(
settingsProvider.select(
(settings) => (
isFirstLaunch: settings.isFirstLaunch,
hasCompletedTutorial: settings.hasCompletedTutorial,
),
),
);
final initialLocation = initialLocationForAppState(
isFirstLaunch: routingSettings.isFirstLaunch,
hasCompletedTutorial: routingSettings.hasCompletedTutorial,
);
return GoRouter(
navigatorKey: AppNavigationService.rootNavigatorKey,
observers: [AppOrientationObserver()],
initialLocation: initialLocation,
routes: [
GoRoute(path: '/', builder: (context, state) => const MainShell()),
GoRoute(path: '/setup', builder: (context, state) => const SetupScreen()),
GoRoute(
path: '/tutorial',
builder: (context, state) => const TutorialScreen(),
),
],
// Safety net: if a deep link URL (e.g. Spotify/Deezer) somehow reaches
// GoRouter, redirect to home instead of showing "Page Not Found".
errorBuilder: (context, state) => const MainShell(),
);
});
Locale _fallbackLocale(Iterable<Locale> supportedLocales) {
for (final supportedLocale in supportedLocales) {
if (supportedLocale.languageCode == 'en') {
return supportedLocale;
}
}
return supportedLocales.first;
}
Locale _resolveSupportedLocale(
Locale? requestedLocale,
Iterable<Locale> supportedLocales,
) {
if (requestedLocale == null) {
return _fallbackLocale(supportedLocales);
}
for (final supportedLocale in supportedLocales) {
if (supportedLocale.languageCode == requestedLocale.languageCode &&
supportedLocale.countryCode == requestedLocale.countryCode) {
return supportedLocale;
}
}
for (final supportedLocale in supportedLocales) {
if (supportedLocale.languageCode == requestedLocale.languageCode) {
return supportedLocale;
}
}
return _fallbackLocale(supportedLocales);
}
/// iOS-style fluid scroll everywhere: momentum glide + bounce instead of the
/// Android clamping stop. Widgets that set explicit physics (queue filter
/// PageView, bottom sheets) are unaffected.
class _FluidScrollBehavior extends MaterialScrollBehavior {
const _FluidScrollBehavior();
@override
ScrollPhysics getScrollPhysics(BuildContext context) =>
const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics());
}
/// Fades the app content in after an orientation change or a width change
/// across the shell's rail breakpoint (fold/unfold, split-screen resize) —
/// Flutter reflows the layout in a single frame, so without this the new
/// layout pops in.
class _OrientationFade extends StatefulWidget {
final Widget child;
const _OrientationFade({required this.child});
@override
State<_OrientationFade> createState() => _OrientationFadeState();
}
class _OrientationFadeState extends State<_OrientationFade>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
value: 1,
);
Orientation? _lastOrientation;
double? _lastWidth;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final orientation = MediaQuery.orientationOf(context);
final width = MediaQuery.sizeOf(context).width;
final orientationChanged =
_lastOrientation != null && orientation != _lastOrientation;
final crossedRailBreakpoint =
_lastWidth != null &&
useNavigationRailForWidth(_lastWidth!) !=
useNavigationRailForWidth(width);
if (orientationChanged || crossedRailBreakpoint) {
_controller.forward(from: 0);
}
_lastOrientation = orientation;
_lastWidth = width;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: CurvedAnimation(parent: _controller, curve: Curves.easeOut),
child: widget.child,
);
}
}
class SpotiFLACApp extends ConsumerWidget {
final bool disableOverscrollEffects;
const SpotiFLACApp({super.key, this.disableOverscrollEffects = false});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(_routerProvider);
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
final heroAnimationsEnabled = ref.watch(
settingsProvider.select((s) => s.heroAnimationsEnabled),
);
final scrollBehavior = disableOverscrollEffects
? const MaterialScrollBehavior().copyWith(overscroll: false)
: const _FluidScrollBehavior();
Locale? locale;
if (localeString != 'system' && localeString.isNotEmpty) {
if (localeString.contains('_')) {
final parts = localeString.split('_');
if (parts.length == 2) {
locale = Locale(parts[0], parts[1]);
} else {
locale = Locale(parts[0]);
}
} else {
locale = Locale(localeString);
}
}
return DynamicColorWrapper(
builder: (lightTheme, darkTheme, themeMode) {
return MaterialApp.router(
title: AppInfo.appName,
debugShowCheckedModeBanner: false,
theme: lightTheme,
darkTheme: darkTheme,
themeMode: themeMode,
scrollBehavior: scrollBehavior,
themeAnimationDuration: const Duration(milliseconds: 300),
themeAnimationCurve: Curves.easeInOut,
// Treat the display as one continuous surface so bottom sheets and
// dialogs stay centered on large/foldable devices.
builder: (context, child) {
final mediaQuery = MediaQuery.of(context);
final appContent = _OrientationFade(
child: child ?? const SizedBox.shrink(),
);
return MediaQuery(
data: mediaQuery.copyWith(displayFeatures: const []),
// HeroMode only affects heroes below a *route* subtree. At this
// level it sits above the router's Navigator, so the controller
// never encounters it while collecting heroes. Removing the
// inherited controller disables flights on the root Navigator.
child: AdaptiveUiScaler(
child: heroAnimationsEnabled
? appContent
: HeroControllerScope.none(child: appContent),
),
);
},
routerConfig: router,
locale: locale,
localeResolutionCallback: (deviceLocale, supportedLocales) {
return _resolveSupportedLocale(
locale ?? deviceLocale,
supportedLocales,
);
},
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
// ARB files below the translation threshold remain bundled so
// translators can test them, but production users should only be
// offered locales selected by the completion checker.
supportedLocales: filteredSupportedLocales,
);
},
);
}
}