feat(onboarding): add theme-aware 5.0 upgrade introduction

This commit is contained in:
zarzet
2026-09-22 00:15:29 +07:00
parent 4283126381
commit 3eea711ed3
7 changed files with 976 additions and 0 deletions
+30
View File
@@ -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",
+30
View File
@@ -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",
+33
View File
@@ -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<MainShell>
_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<MainShell>
}
}
Future<void> _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<void>(
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<bool> _checkForUpdates() async {
if (_hasCheckedUpdate) return false;
_hasCheckedUpdate = true;
+19
View File
@@ -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<void>(
MaterialPageRoute(
builder: (_) => const UpgradeIntroScreen(),
fullscreenDialog: true,
),
),
),
],
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.aboutContributors,
+607
View File
@@ -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<UpgradeIntroScreen> createState() => _UpgradeIntroScreenState();
}
class _UpgradeIntroScreenState extends State<UpgradeIntroScreen> {
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<Widget> 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),
),
],
],
),
),
);
}
}
+20
View File
@@ -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<void> markSeen(SharedPreferences preferences) async {
await preferences.setBool(_seenKey, true);
}
}
+237
View File
@@ -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<void> 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<void>(
builder: (_) => const UpgradeIntroScreen(),
),
),
child: const Text('Open tour'),
),
),
),
);
},
),
),
);
await tester.tap(find.text('Open tour'));
await tester.pumpAndSettle();
}
Future<void> 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);
},
);
}
}