mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 12:52:13 +02:00
feat(onboarding): add theme-aware 5.0 upgrade introduction
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user