From 3eea711ed3ecc57d317812aa0b510320a6de7f2d Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:15:29 +0700 Subject: [PATCH] feat(onboarding): add theme-aware 5.0 upgrade introduction --- lib/l10n/arb/app_en.arb | 30 ++ lib/l10n/arb/app_id.arb | 30 ++ lib/screens/main_shell.dart | 33 ++ lib/screens/settings/about_page.dart | 19 + lib/screens/upgrade_intro_screen.dart | 607 ++++++++++++++++++++++++ lib/services/upgrade_intro_service.dart | 20 + test/upgrade_intro_test.dart | 237 +++++++++ 7 files changed, 976 insertions(+) create mode 100644 lib/screens/upgrade_intro_screen.dart create mode 100644 lib/services/upgrade_intro_service.dart create mode 100644 test/upgrade_intro_test.dart diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 08e87ecb..943db927 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1,4 +1,34 @@ { + "upgradeIntroWelcome": "Welcome to\nSpotiFLAC 5.0", + "upgradeIntroThemeTitle": "Make it\nyour own.", + "upgradeIntroThemeBody": "Choose a look. Your choice applies immediately, and you can change it later in Appearance.", + "upgradeIntroThemeMaterialBody": "The familiar layout, with Material controls and your accent color.", + "upgradeIntroThemeMornyeBody": "Glass navigation, a redesigned Library and an immersive player.", + "upgradeIntroRustTitle": "A new engine.\nBuilt with Rust.", + "upgradeIntroRustBody": "In 5.0, the app’s backend has moved from Go to Rust. It is the engine working behind the screens you use every day.", + "upgradeIntroRustEngineTitle": "Working behind the scenes", + "upgradeIntroRustEngineBody": "Rust handles downloads, audio metadata, library scanning and the extension runtime on Android and iOS.", + "upgradeIntroRustMemoryTitle": "More careful with memory", + "upgradeIntroRustMemoryBody": "Reused buffers and fewer copies reduce temporary allocations when processing artwork and extension data.", + "upgradeIntroRustKeepTitle": "Keep your music", + "upgradeIntroRustKeepBody": "No need to download your collection again. Your saved music and settings stay in place, with no new setup required.", + "upgradeIntroWelcomeBody": "A quick look at what has changed, and where to find it.", + "upgradeIntroMornyeBody": "A new look for your player, lyrics and navigation.", + "upgradeIntroLibrarySummary": "Browse your music by playlist, artist or album.", + "upgradeIntroRepoSummary": "Choose your music sources and providers through extensions.", + "upgradeIntroLibraryTitle": "Get to know\nyour Library.", + "upgradeIntroLibraryBody": "In Mornye, browse by category or find your albums in Recently Added.", + "upgradeIntroDownloadsBody": "Pause and retry from Downloads at the top right. Finished music moves to Recently Added.", + "upgradeIntroSearchBody": "Search saved music here. The separate Search tab looks through your extensions.", + "upgradeIntroPlayerTitle": "Set up\nyour player.", + "upgradeIntroPlayerBody": "You can change your theme and playback options at any time. Here is where to find them.", + "upgradeIntroAppearanceBody": "Change themes in Settings → Appearance. In Mornye, scroll down for compact navigation; scroll up to expand it.", + "upgradeIntroAutoMixBody": "Match tempo and beats between tracks. Enable it in Settings → Local Library → Playback. Off by default.", + "upgradeIntroSettingsBody": "In Mornye, tap your profile on Home to open Settings and edit your name and photo.", + "upgradeIntroSkip": "Skip", + "upgradeIntroContinue": "Continue", + "upgradeIntroDone": "Start listening", + "upgradeIntroReplay": "What’s new in 5.0", "cacheMotionArtwork": "Offline motion covers", "cacheMotionArtworkDesc": "Album videos saved with downloads. Removing them keeps your music files and static covers.", "nowPlayingVolume": "Volume", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 3fa734e6..2fb0185d 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -1,4 +1,34 @@ { + "upgradeIntroWelcome": "Selamat datang di\nSpotiFLAC 5.0", + "upgradeIntroThemeTitle": "Pilih\ntampilanmu.", + "upgradeIntroThemeBody": "Pilih tema yang kamu suka. Pilihan langsung diterapkan dan bisa diubah lagi lewat Tampilan.", + "upgradeIntroThemeMaterialBody": "Tampilan yang sudah familiar, dengan kontrol Material dan warna aksen pilihanmu.", + "upgradeIntroThemeMornyeBody": "Navigasi kaca, Library yang ditata ulang, dan player dengan artwork memenuhi layar.", + "upgradeIntroRustTitle": "Mesin baru.\nDibangun dengan Rust.", + "upgradeIntroRustBody": "Di versi 5.0, backend aplikasi beralih dari Go ke Rust. Inilah mesin yang bekerja di balik layar yang kamu gunakan setiap hari.", + "upgradeIntroRustEngineTitle": "Bekerja di balik layar", + "upgradeIntroRustEngineBody": "Rust menangani unduhan, metadata audio, pemindaian Library, dan runtime extension di Android serta iOS.", + "upgradeIntroRustMemoryTitle": "Lebih hemat alokasi memori", + "upgradeIntroRustMemoryBody": "Buffer dipakai ulang dan penyalinan data dikurangi saat mengolah artwork serta data extension, sehingga alokasi sementara lebih sedikit.", + "upgradeIntroRustKeepTitle": "Musikmu tetap tersimpan", + "upgradeIntroRustKeepBody": "Tidak perlu mengunduh ulang koleksimu. Musik dan pengaturan yang tersimpan tetap tersedia, tanpa pengaturan tambahan.", + "upgradeIntroWelcomeBody": "Kenali perubahan terbaru dan tempat menemukannya.", + "upgradeIntroMornyeBody": "Tampilan baru untuk player, lirik, dan navigasi.", + "upgradeIntroLibrarySummary": "Jelajahi musik berdasarkan playlist, artis, atau album.", + "upgradeIntroRepoSummary": "Pilih sumber musik dan penyedia layanan melalui extension.", + "upgradeIntroLibraryTitle": "Kenali\nLibrary barumu.", + "upgradeIntroLibraryBody": "Di Mornye, jelajahi musik per kategori atau temukan albummu di Baru Ditambahkan.", + "upgradeIntroDownloadsBody": "Jeda dan coba lagi lewat Unduhan di kanan atas. Musik yang selesai masuk ke Baru Ditambahkan.", + "upgradeIntroSearchBody": "Cari musik tersimpan di sini. Tab Search terpisah mencari musik dari extension.", + "upgradeIntroPlayerTitle": "Atur\nplayer-mu.", + "upgradeIntroPlayerBody": "Tema dan pilihan pemutaran bisa kamu ubah kapan saja. Berikut tempat menemukannya.", + "upgradeIntroAppearanceBody": "Ubah tema di Pengaturan → Tampilan. Di Mornye, gulir ke bawah untuk navigasi ringkas, ke atas untuk memunculkan tab lagi.", + "upgradeIntroAutoMixBody": "Cocokkan tempo dan beat antar lagu. Aktifkan di Pengaturan → Local Library → Playback. Awalnya nonaktif.", + "upgradeIntroSettingsBody": "Di Mornye, ketuk profil di Home untuk membuka Pengaturan dan mengubah nama serta foto.", + "upgradeIntroSkip": "Lewati", + "upgradeIntroContinue": "Lanjut", + "upgradeIntroDone": "Mulai mendengarkan", + "upgradeIntroReplay": "Yang baru di 5.0", "cacheMotionArtwork": "Cover bergerak offline", "cacheMotionArtworkDesc": "Video album yang disimpan saat download. Menghapusnya tetap menyimpan file musik dan cover biasa.", "nowPlayingVolume": "Volume", diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 22664192..9908b63d 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -9,6 +9,9 @@ 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/upgrade_intro_service.dart'; import 'package:spotiflac_android/services/discord_presence_service.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; @@ -140,6 +143,8 @@ class _MainShellState extends ConsumerState _initialSafRepairComplete = true; if (!mounted) return; unawaited(restorePersistedPlaybackSession()); + await _checkUpgradeIntro(); + if (!mounted) return; _setupShareListener(); await _checkSafMigration(); final updateDialogShown = await _checkForUpdates(); @@ -339,6 +344,34 @@ class _MainShellState extends ConsumerState } } + Future _checkUpgradeIntro() async { + try { + final preferences = await SharedPreferences.getInstance(); + if (!mounted) return; + final existingInstallation = !ref + .read(initialSettingsProvider) + .isFirstLaunch; + if (UpgradeIntroService.shouldShow( + preferences, + version: AppInfo.version, + existingInstallation: existingInstallation, + )) { + await Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => const UpgradeIntroScreen(), + fullscreenDialog: true, + ), + ); + await UpgradeIntroService.markSeen(preferences); + } else if (!existingInstallation) { + // Fresh installs already have their own setup tutorial. + await UpgradeIntroService.markSeen(preferences); + } + } catch (error) { + _log.w('Could not present upgrade introduction: $error'); + } + } + Future _checkForUpdates() async { if (_hasCheckedUpdate) return false; _hasCheckedUpdate = true; diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index a866966f..6c4d7a6c 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -9,6 +9,7 @@ import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; +import 'package:spotiflac_android/screens/upgrade_intro_screen.dart'; class AboutPage extends StatelessWidget { const AboutPage({super.key}); @@ -36,6 +37,24 @@ class AboutPage extends StatelessWidget { ), ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsItem( + icon: Icons.new_releases, + title: context.l10n.upgradeIntroReplay, + showDivider: false, + onTap: () => + Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => const UpgradeIntroScreen(), + fullscreenDialog: true, + ), + ), + ), + ], + ), + ), SliverToBoxAdapter( child: SettingsSectionHeader( title: context.l10n.aboutContributors, diff --git a/lib/screens/upgrade_intro_screen.dart b/lib/screens/upgrade_intro_screen.dart new file mode 100644 index 00000000..3d9f55c5 --- /dev/null +++ b/lib/screens/upgrade_intro_screen.dart @@ -0,0 +1,607 @@ +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/theme_settings.dart'; +import 'package:spotiflac_android/providers/theme_provider.dart'; +import 'package:spotiflac_android/theme/app_theme.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +/// A local, replayable release tour. Theme changes require an explicit choice. +class UpgradeIntroScreen extends StatefulWidget { + const UpgradeIntroScreen({super.key}); + + @override + State createState() => _UpgradeIntroScreenState(); +} + +class _UpgradeIntroScreenState extends State { + final _pages = PageController(); + int _page = 0; + + @override + void dispose() { + _pages.dispose(); + super.dispose(); + } + + void _goTo(int page) { + if (MediaQuery.disableAnimationsOf(context)) { + _pages.jumpToPage(page); + } else { + _pages.animateToPage( + page, + duration: const Duration(milliseconds: 320), + curve: Curves.easeInOutCubic, + ); + } + } + + void _finish() => Navigator.of(context).pop(); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final scheme = Theme.of(context).colorScheme; + final mornye = context.isMornye; + final pageCount = mornye ? 5 : 3; + final last = _page == pageCount - 1; + return PopScope( + canPop: _page == 0, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _goTo(_page - 1); + }, + child: Scaffold( + backgroundColor: scheme.surface, + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: Row( + children: [ + if (_page > 0) + IconButton( + tooltip: MaterialLocalizations.of( + context, + ).backButtonTooltip, + onPressed: () => _goTo(_page - 1), + color: scheme.primary, + icon: Icon( + mornye + ? CupertinoIcons.chevron_back + : Icons.arrow_back, + ), + ) + else + const SizedBox(width: 48, height: 48), + const Spacer(), + TextButton( + onPressed: _finish, + child: Text(l10n.upgradeIntroSkip), + ), + ], + ), + ), + Expanded( + child: PageView( + controller: _pages, + onPageChanged: (page) => setState(() => _page = page), + children: [ + _TourPage( + title: l10n.upgradeIntroWelcome, + description: l10n.upgradeIntroWelcomeBody, + children: [ + _TourFeature( + icon: Icons.play_circle_outline, + title: 'Mornye', + body: l10n.upgradeIntroMornyeBody, + ), + _TourFeature( + icon: Icons.library_music_outlined, + title: l10n.navLibrary, + body: l10n.upgradeIntroLibrarySummary, + ), + _TourFeature( + icon: Icons.extension_outlined, + title: l10n.navStore, + body: l10n.upgradeIntroRepoSummary, + ), + ], + ), + _TourPage( + title: l10n.upgradeIntroThemeTitle, + description: l10n.upgradeIntroThemeBody, + children: const [_ThemePicker()], + ), + if (mornye) + _TourPage( + title: l10n.upgradeIntroLibraryTitle, + description: l10n.upgradeIntroLibraryBody, + children: [ + const _LibraryPreview(), + const SizedBox(height: 28), + _TourFeature( + icon: Icons.downloading_outlined, + title: l10n.libraryDownloads, + body: l10n.upgradeIntroDownloadsBody, + ), + _TourFeature( + icon: Icons.search, + title: l10n.mornyeSearch, + body: l10n.upgradeIntroSearchBody, + ), + ], + ), + _TourPage( + title: l10n.upgradeIntroRustTitle, + description: l10n.upgradeIntroRustBody, + hero: Icon( + Icons.memory_outlined, + size: 76, + color: scheme.primary, + ), + children: [ + _TourFeature( + icon: Icons.download_outlined, + title: l10n.upgradeIntroRustEngineTitle, + body: l10n.upgradeIntroRustEngineBody, + ), + _TourFeature( + icon: Icons.memory_outlined, + title: l10n.upgradeIntroRustMemoryTitle, + body: l10n.upgradeIntroRustMemoryBody, + ), + _TourFeature( + icon: Icons.library_music_outlined, + title: l10n.upgradeIntroRustKeepTitle, + body: l10n.upgradeIntroRustKeepBody, + ), + ], + ), + if (mornye) + _TourPage( + title: l10n.upgradeIntroPlayerTitle, + description: l10n.upgradeIntroPlayerBody, + hero: Icon( + CupertinoIcons.music_note_2, + size: 76, + color: scheme.primary, + ), + children: [ + _TourFeature( + icon: Icons.palette_outlined, + title: l10n.appearanceTitle, + body: l10n.upgradeIntroAppearanceBody, + ), + _TourFeature( + icon: Icons.graphic_eq, + title: 'AutoMix', + body: l10n.upgradeIntroAutoMixBody, + ), + _TourFeature( + icon: Icons.person_outline, + title: l10n.navSettings, + body: l10n.upgradeIntroSettingsBody, + ), + ], + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(32, 16, 32, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + label: '${_page + 1} / $pageCount', + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + pageCount, + (index) => Container( + margin: const EdgeInsets.symmetric( + horizontal: 4, + ), + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: index == _page + ? scheme.primary + : scheme.onSurface.withValues( + alpha: 0.16, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: last ? _finish : () => _goTo(_page + 1), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(54), + shape: mornye + ? RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ) + : null, + textStyle: Theme.of(context).textTheme.labelLarge + ?.copyWith( + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + child: Text( + last + ? l10n.upgradeIntroDone + : l10n.upgradeIntroContinue, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _TourPage extends StatelessWidget { + const _TourPage({ + required this.title, + required this.description, + required this.children, + this.hero, + }); + final String title; + final String description; + final List children; + final Widget? hero; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(32, 16, 32, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hero != null) ...[hero!, const SizedBox(height: 22)], + Text( + title, + style: theme.textTheme.headlineLarge?.copyWith( + fontSize: context.isMornye ? 36 : 34, + height: context.isMornye ? 1.08 : 1.18, + letterSpacing: context.isMornye ? -1.2 : null, + fontWeight: context.isMornye ? FontWeight.w700 : FontWeight.w600, + ), + ), + const SizedBox(height: 16), + Text( + description, + style: theme.textTheme.bodyLarge?.copyWith( + fontSize: 17, + height: 1.4, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 32), + ...children, + ], + ), + ); + } +} + +class _ThemePicker extends ConsumerWidget { + const _ThemePicker(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(themeProvider); + final brightness = Theme.of(context).brightness; + final l10n = context.l10n; + return Column( + children: [ + for (final style in AppThemeStyle.values) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Theme( + data: style == AppThemeStyle.mornye + ? MornyeTheme.build(brightness) + : brightness == Brightness.dark + ? AppTheme.dark( + seedColor: settings.seedColor, + isAmoled: settings.useAmoled, + ) + : AppTheme.light(seedColor: settings.seedColor), + child: _ThemeOption( + style: style, + selected: settings.style == style, + label: style == AppThemeStyle.mornye ? 'Mornye' : 'Material', + description: style == AppThemeStyle.mornye + ? l10n.upgradeIntroThemeMornyeBody + : l10n.upgradeIntroThemeMaterialBody, + onTap: () => ref.read(themeProvider.notifier).setStyle(style), + ), + ), + ), + ], + ); + } +} + +class _ThemeOption extends StatelessWidget { + const _ThemeOption({ + required this.style, + required this.selected, + required this.label, + required this.description, + required this.onTap, + }); + + final AppThemeStyle style; + final bool selected; + final String label; + final String description; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final mornye = style == AppThemeStyle.mornye; + return Semantics( + selected: selected, + child: Material( + color: scheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(mornye ? 20 : 28), + side: BorderSide( + color: selected ? scheme.primary : scheme.outlineVariant, + width: selected ? 2 : 1, + ), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + key: ValueKey('intro-theme-${style.name}'), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + label, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 12), + Icon( + selected + ? Icons.check_circle + : Icons.radio_button_unchecked, + color: selected + ? scheme.primary + : scheme.onSurfaceVariant, + ), + ], + ), + const SizedBox(height: 12), + Text( + description, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + height: 1.4, + ), + ), + const SizedBox(height: 20), + ExcludeSemantics( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + for (final icon in [ + Icons.home_rounded, + Icons.library_music, + Icons.extension, + mornye ? Icons.search : Icons.settings_outlined, + ]) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: icon == Icons.home_rounded + ? mornye + ? scheme.onSurface.withValues(alpha: 0.08) + : scheme.secondaryContainer + : Colors.transparent, + borderRadius: BorderRadius.circular(24), + ), + child: Icon( + mornye + ? icon == Icons.home_rounded + ? CupertinoIcons.house_fill + : mornyeIconFor(icon) + : icon, + size: 22, + color: icon == Icons.home_rounded + ? scheme.primary + : scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _TourFeature extends StatelessWidget { + const _TourFeature({ + required this.icon, + required this.title, + required this.body, + }); + final IconData icon; + final String title; + final String body; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 36, + child: Icon( + context.isMornye ? mornyeIconFor(icon) : icon, + size: 28, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + body, + style: theme.textTheme.bodyMedium?.copyWith( + height: 1.35, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// A small map of the new Library, using its actual labels and icon language. +/// Decorative rows deliberately have no buttons or fake playback state. +class _LibraryPreview extends StatelessWidget { + const _LibraryPreview(); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return ExcludeSemantics( + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: scheme.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + alignment: AlignmentDirectional.centerEnd, + child: Text.rich( + TextSpan( + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Icon( + CupertinoIcons.arrow_down_circle, + size: 18, + color: scheme.primary, + ), + ), + TextSpan(text: ' ${l10n.libraryDownloads}'), + ], + ), + style: TextStyle( + color: scheme.primary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 12), + Text( + l10n.navLibrary, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + for (final item in [ + (CupertinoIcons.music_note_list, l10n.searchPlaylists), + (CupertinoIcons.mic, l10n.searchArtists), + (CupertinoIcons.square_stack, l10n.searchAlbums), + ]) ...[ + Padding( + padding: const EdgeInsets.symmetric(vertical: 9), + child: Row( + children: [ + Icon(item.$1, color: scheme.primary, size: 22), + const SizedBox(width: 12), + Expanded( + child: Text(item.$2, style: theme.textTheme.bodyLarge), + ), + Icon( + CupertinoIcons.chevron_right, + size: 14, + color: scheme.onSurfaceVariant, + ), + ], + ), + ), + Divider( + height: 1, + color: scheme.outlineVariant.withValues(alpha: 0.5), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/services/upgrade_intro_service.dart b/lib/services/upgrade_intro_service.dart new file mode 100644 index 00000000..7cc2e777 --- /dev/null +++ b/lib/services/upgrade_intro_service.dart @@ -0,0 +1,20 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Separate from settings migrations: migrating preferences must not consume +/// the upgrade introduction before the user has actually seen it. +class UpgradeIntroService { + static const _seenKey = 'upgrade_intro_5_0_seen'; + + static bool shouldShow( + SharedPreferences preferences, { + required String version, + required bool existingInstallation, + }) => + version.split('.').first == '5' && + existingInstallation && + !(preferences.getBool(_seenKey) ?? false); + + static Future markSeen(SharedPreferences preferences) async { + await preferences.setBool(_seenKey, true); + } +} diff --git a/test/upgrade_intro_test.dart b/test/upgrade_intro_test.dart new file mode 100644 index 00000000..6057ddfa --- /dev/null +++ b/test/upgrade_intro_test.dart @@ -0,0 +1,237 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/theme_settings.dart'; +import 'package:spotiflac_android/providers/theme_provider.dart'; +import 'package:spotiflac_android/screens/upgrade_intro_screen.dart'; +import 'package:spotiflac_android/services/upgrade_intro_service.dart'; +import 'package:spotiflac_android/theme/app_theme.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + await (FontLoader( + 'MaterialIcons', + )..addFont(rootBundle.load('fonts/MaterialIcons-Regular.otf'))).load(); + await (FontLoader( + 'Google Sans Flex', + )..addFont(rootBundle.load('assets/fonts/GoogleSansFlex.ttf'))).load(); + await (FontLoader( + 'Inter', + )..addFont(rootBundle.load('assets/fonts/InterVariable.ttf'))).load(); + await (FontLoader('packages/cupertino_icons/CupertinoIcons')..addFont( + rootBundle.load('packages/cupertino_icons/assets/CupertinoIcons.ttf'), + )) + .load(); + }); + + test( + 'upgrade tour is acknowledged separately from settings migrations', + () async { + SharedPreferences.setMockInitialValues({ + 'settings_migration_version': 11, + }); + var prefs = await SharedPreferences.getInstance(); + bool pending(String version, {bool existing = true}) => + UpgradeIntroService.shouldShow( + prefs, + version: version, + existingInstallation: existing, + ); + expect(pending('5.0.0'), isTrue); + expect(pending('5.0.1'), isTrue); + expect(pending('4.9.0'), isFalse); + expect(pending('6.0.0'), isFalse); + expect(pending('5.0.0', existing: false), isFalse); + await UpgradeIntroService.markSeen(prefs); + prefs = await SharedPreferences.getInstance(); + expect(pending('5.0.0'), isFalse); + expect(prefs.getInt('settings_migration_version'), 11); + }, + ); + + final capture = GlobalKey(); + Future openTour( + WidgetTester tester, { + Size size = const Size(390, 844), + double textScale = 1, + Brightness brightness = Brightness.light, + AppThemeStyle style = AppThemeStyle.mornye, + }) async { + SharedPreferences.setMockInitialValues({kThemeStyleKey: style.name}); + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ProviderScope( + overrides: [ + initialThemeSettingsProvider.overrideWithValue( + ThemeSettings(style: style), + ), + ], + child: Consumer( + builder: (context, ref, _) { + final selectedStyle = ref.watch( + themeProvider.select((value) => value.style), + ); + return MaterialApp( + theme: selectedStyle == AppThemeStyle.mornye + ? MornyeTheme.build(brightness) + : brightness == Brightness.dark + ? AppTheme.dark() + : AppTheme.light(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: RepaintBoundary(key: capture, child: child), + ), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const UpgradeIntroScreen(), + ), + ), + child: const Text('Open tour'), + ), + ), + ), + ); + }, + ), + ), + ); + await tester.tap(find.text('Open tour')); + await tester.pumpAndSettle(); + } + + Future capturePage(WidgetTester tester, String name) async { + if (!const bool.fromEnvironment('CAPTURE_UPGRADE_INTRO')) return; + await tester.runAsync(() async { + final boundary = + capture.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 2); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + await File( + '/tmp/spotiflac-intro-$name.png', + ).writeAsBytes(bytes!.buffer.asUint8List()); + image.dispose(); + }); + } + + for (final brightness in Brightness.values) { + testWidgets('tour advances, goes back and finishes ($brightness)', ( + tester, + ) async { + await openTour(tester, brightness: brightness); + expect(find.text('Welcome to\nSpotiFLAC 5.0'), findsOneWidget); + expect(find.byType(Image), findsNothing); + await capturePage(tester, '${brightness.name}-welcome'); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + expect(find.text('Make it\nyour own.'), findsOneWidget); + await capturePage(tester, '${brightness.name}-themes'); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('Welcome to\nSpotiFLAC 5.0'), findsOneWidget); + for (var i = 0; i < 4; i++) { + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + if (i == 1) { + expect(find.text('Get to know\nyour Library.'), findsOneWidget); + await capturePage(tester, '${brightness.name}-library'); + } + if (i == 2) { + expect(find.text('A new engine.\nBuilt with Rust.'), findsOneWidget); + await capturePage(tester, '${brightness.name}-rust'); + } + } + await capturePage(tester, '${brightness.name}-player'); + await tester.tap(find.text('Start listening')); + await tester.pumpAndSettle(); + expect(find.byType(UpgradeIntroScreen), findsNothing); + expect(find.text('Open tour'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('theme choice applies live, persists and updates the guide', ( + tester, + ) async { + await openTour(tester, style: AppThemeStyle.material); + await capturePage(tester, 'material-welcome'); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString(kThemeStyleKey), 'material'); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + final mornye = find.byKey(const ValueKey('intro-theme-mornye')); + await tester.ensureVisible(mornye); + await tester.tap(mornye); + await tester.pumpAndSettle(); + expect(prefs.getString(kThemeStyleKey), 'mornye'); + expect(tester.element(find.byType(UpgradeIntroScreen)).isMornye, isTrue); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + expect(find.textContaining('Recently Added.'), findsWidgets); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + final material = find.byKey(const ValueKey('intro-theme-material')); + await tester.ensureVisible(material); + await tester.tap(material); + await tester.pumpAndSettle(); + expect(prefs.getString(kThemeStyleKey), 'material'); + expect(tester.element(find.byType(UpgradeIntroScreen)).isMornye, isFalse); + await capturePage(tester, 'material-themes'); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + expect(find.text('A new engine.\nBuilt with Rust.'), findsOneWidget); + expect(find.text('Get to know\nyour Library.'), findsNothing); + expect(find.text('Set up\nyour player.'), findsNothing); + expect(find.bySemanticsLabel('3 / 3'), findsOneWidget); + expect(find.text('Continue'), findsNothing); + await capturePage(tester, 'material-rust'); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('Make it\nyour own.'), findsOneWidget); + await tester.drag(find.byType(PageView), const Offset(-500, 0)); + await tester.pumpAndSettle(); + expect(find.text('A new engine.\nBuilt with Rust.'), findsOneWidget); + await tester.tap(find.text('Start listening')); + await tester.pumpAndSettle(); + expect(prefs.getString(kThemeStyleKey), 'material'); + expect(tester.takeException(), isNull); + }); + + for (final size in [const Size(320, 568), const Size(844, 390)]) { + testWidgets( + 'tour scrolls with large text and can skip from any page ($size)', + (tester) async { + await openTour(tester, size: size, textScale: 2); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + await tester.drag( + find.byType(SingleChildScrollView).first, + const Offset(0, -400), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.tap(find.text('Skip')); + await tester.pumpAndSettle(); + expect(find.byType(UpgradeIntroScreen), findsNothing); + }, + ); + } +}