From 96a2a1f5a622cb3c580041417d5023e37fa69716 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 25 Feb 2024 22:01:38 +0600 Subject: [PATCH] feat: add getting started page --- lib/collections/intents.dart | 3 +- lib/collections/language_codes.dart | 5 + lib/collections/routes.dart | 299 ++++++++++-------- lib/collections/spotube_icons.dart | 3 + lib/components/getting_started/blur_card.dart | 31 ++ lib/hooks/configurators/use_deep_linking.dart | 2 + lib/main.dart | 5 + .../getting_started/getting_started.dart | 93 ++++++ .../getting_started/sections/greeting.dart | 54 ++++ .../getting_started/sections/playback.dart | 154 +++++++++ .../getting_started/sections/region.dart | 129 ++++++++ .../getting_started/sections/support.dart | 131 ++++++++ lib/pages/settings/sections/appearance.dart | 175 +++++----- lib/services/kv_store/kv_store.dart | 15 + lib/utils/persisted_state_notifier.dart | 4 +- 15 files changed, 887 insertions(+), 216 deletions(-) create mode 100644 lib/components/getting_started/blur_card.dart create mode 100644 lib/pages/getting_started/getting_started.dart create mode 100644 lib/pages/getting_started/sections/greeting.dart create mode 100644 lib/pages/getting_started/sections/playback.dart create mode 100644 lib/pages/getting_started/sections/region.dart create mode 100644 lib/pages/getting_started/sections/support.dart create mode 100644 lib/services/kv_store/kv_store.dart diff --git a/lib/collections/intents.dart b/lib/collections/intents.dart index abccb3ad9..6f42113cd 100644 --- a/lib/collections/intents.dart +++ b/lib/collections/intents.dart @@ -4,8 +4,8 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:spotube/components/player/player_controls.dart'; import 'package:spotube/collections/routes.dart'; +import 'package:spotube/components/player/player_controls.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -64,6 +64,7 @@ class HomeTabIntent extends Intent { class HomeTabAction extends Action { @override invoke(intent) { + final router = intent.ref.read(routerProvider); switch (intent.tab) { case HomeTabs.browse: router.go("/"); diff --git a/lib/collections/language_codes.dart b/lib/collections/language_codes.dart index 4554de630..e3b71d3fd 100644 --- a/lib/collections/language_codes.dart +++ b/lib/collections/language_codes.dart @@ -6,6 +6,11 @@ class ISOLanguageName { required this.name, required this.nativeName, }); + + @override + String toString() { + return "$name ($nativeName)"; + } } // Uncomment the languages as we add support for them diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 3e2c42e07..7b0d836df 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -2,8 +2,10 @@ import 'package:catcher_2/catcher_2.dart'; import 'package:flutter/foundation.dart' hide Category; import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart' hide Search; import 'package:spotube/pages/album/album.dart'; +import 'package:spotube/pages/getting_started/getting_started.dart'; import 'package:spotube/pages/home/genres/genre_playlists.dart'; import 'package:spotube/pages/home/genres/genres.dart'; import 'package:spotube/pages/home/home.dart'; @@ -18,6 +20,8 @@ import 'package:spotube/pages/settings/blacklist.dart'; import 'package:spotube/pages/settings/about.dart'; import 'package:spotube/pages/settings/logs.dart'; import 'package:spotube/pages/track/track.dart'; +import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/components/shared/spotube_page_route.dart'; import 'package:spotube/pages/artist/artist.dart'; @@ -31,157 +35,180 @@ import 'package:spotube/pages/mobile_login/mobile_login.dart'; final rootNavigatorKey = Catcher2.navigatorKey; final shellRouteNavigatorKey = GlobalKey(); -final router = GoRouter( - navigatorKey: rootNavigatorKey, - routes: [ - ShellRoute( - navigatorKey: shellRouteNavigatorKey, - builder: (context, state, child) => RootApp(child: child), - routes: [ - GoRoute( - path: "/", - pageBuilder: (context, state) => const SpotubePage(child: HomePage()), - routes: [ - GoRoute( - path: "genres", - pageBuilder: (context, state) => - const SpotubePage(child: GenrePage()), - ), - GoRoute( - path: "genre/:categoryId", - pageBuilder: (context, state) => SpotubePage( - child: GenrePlaylistsPage( - category: state.extra as Category, - ), - ), - ), - ], - ), - GoRoute( - path: "/search", - name: "Search", - pageBuilder: (context, state) => - const SpotubePage(child: SearchPage()), - ), - GoRoute( - path: "/library", - name: "Library", +final routerProvider = Provider((ref) { + return GoRouter( + navigatorKey: rootNavigatorKey, + routes: [ + ShellRoute( + navigatorKey: shellRouteNavigatorKey, + builder: (context, state, child) => RootApp(child: child), + routes: [ + GoRoute( + path: "/", + redirect: (context, state) async { + final authNotifier = + ref.read(AuthenticationNotifier.provider.notifier); + final json = await authNotifier.box.get(authNotifier.cacheKey); + + if (json["cookie"] == null && + !KVStoreService.doneGettingStarted) { + return "/getting-started"; + } + + return null; + }, pageBuilder: (context, state) => - const SpotubePage(child: LibraryPage()), + const SpotubePage(child: HomePage()), routes: [ GoRoute( - path: "generate", - pageBuilder: (context, state) => - const SpotubePage(child: PlaylistGeneratorPage()), - routes: [ - GoRoute( - path: "result", - pageBuilder: (context, state) => SpotubePage( - child: PlaylistGenerateResultPage( - state: - state.extra as PlaylistGenerateResultRouteState, + path: "genres", + pageBuilder: (context, state) => + const SpotubePage(child: GenrePage()), + ), + GoRoute( + path: "genre/:categoryId", + pageBuilder: (context, state) => SpotubePage( + child: GenrePlaylistsPage( + category: state.extra as Category, + ), + ), + ), + ], + ), + GoRoute( + path: "/search", + name: "Search", + pageBuilder: (context, state) => + const SpotubePage(child: SearchPage()), + ), + GoRoute( + path: "/library", + name: "Library", + pageBuilder: (context, state) => + const SpotubePage(child: LibraryPage()), + routes: [ + GoRoute( + path: "generate", + pageBuilder: (context, state) => + const SpotubePage(child: PlaylistGeneratorPage()), + routes: [ + GoRoute( + path: "result", + pageBuilder: (context, state) => SpotubePage( + child: PlaylistGenerateResultPage( + state: + state.extra as PlaylistGenerateResultRouteState, + ), ), ), - ), - ]), - ]), - GoRoute( - path: "/lyrics", - name: "Lyrics", - pageBuilder: (context, state) => - const SpotubePage(child: LyricsPage()), - ), - GoRoute( - path: "/settings", - pageBuilder: (context, state) => const SpotubePage( - child: SettingsPage(), + ]), + ]), + GoRoute( + path: "/lyrics", + name: "Lyrics", + pageBuilder: (context, state) => + const SpotubePage(child: LyricsPage()), ), - routes: [ - GoRoute( - path: "blacklist", - pageBuilder: (context, state) => SpotubeSlidePage( - child: const BlackListPage(), - ), + GoRoute( + path: "/settings", + pageBuilder: (context, state) => const SpotubePage( + child: SettingsPage(), ), - if (!kIsWeb) + routes: [ GoRoute( - path: "logs", + path: "blacklist", pageBuilder: (context, state) => SpotubeSlidePage( - child: const LogsPage(), + child: const BlackListPage(), ), ), - GoRoute( - path: "about", - pageBuilder: (context, state) => SpotubeSlidePage( - child: const AboutSpotube(), + if (!kIsWeb) + GoRoute( + path: "logs", + pageBuilder: (context, state) => SpotubeSlidePage( + child: const LogsPage(), + ), + ), + GoRoute( + path: "about", + pageBuilder: (context, state) => SpotubeSlidePage( + child: const AboutSpotube(), + ), ), - ), - ], - ), - GoRoute( - path: "/album/:id", - pageBuilder: (context, state) { - assert(state.extra is AlbumSimple); - return SpotubePage( - child: AlbumPage(album: state.extra as AlbumSimple), - ); - }, - ), - GoRoute( - path: "/artist/:id", - pageBuilder: (context, state) { - assert(state.pathParameters["id"] != null); - return SpotubePage(child: ArtistPage(state.pathParameters["id"]!)); - }, + ], + ), + GoRoute( + path: "/album/:id", + pageBuilder: (context, state) { + assert(state.extra is AlbumSimple); + return SpotubePage( + child: AlbumPage(album: state.extra as AlbumSimple), + ); + }, + ), + GoRoute( + path: "/artist/:id", + pageBuilder: (context, state) { + assert(state.pathParameters["id"] != null); + return SpotubePage( + child: ArtistPage(state.pathParameters["id"]!)); + }, + ), + GoRoute( + path: "/playlist/:id", + pageBuilder: (context, state) { + assert(state.extra is PlaylistSimple); + return SpotubePage( + child: state.pathParameters["id"] == "user-liked-tracks" + ? LikedPlaylistPage(playlist: state.extra as PlaylistSimple) + : PlaylistPage(playlist: state.extra as PlaylistSimple), + ); + }, + ), + GoRoute( + path: "/track/:id", + pageBuilder: (context, state) { + final id = state.pathParameters["id"]!; + return SpotubePage( + child: TrackPage(trackId: id), + ); + }, + ), + ], + ), + GoRoute( + path: "/mini-player", + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (context, state) => SpotubePage( + child: MiniLyricsPage(prevSize: state.extra as Size), ), - GoRoute( - path: "/playlist/:id", - pageBuilder: (context, state) { - assert(state.extra is PlaylistSimple); - return SpotubePage( - child: state.pathParameters["id"] == "user-liked-tracks" - ? LikedPlaylistPage(playlist: state.extra as PlaylistSimple) - : PlaylistPage(playlist: state.extra as PlaylistSimple), - ); - }, + ), + GoRoute( + path: "/getting-started", + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (context, state) => const SpotubePage( + child: GettingStarting(), ), - GoRoute( - path: "/track/:id", - pageBuilder: (context, state) { - final id = state.pathParameters["id"]!; - return SpotubePage( - child: TrackPage(trackId: id), - ); - }, + ), + GoRoute( + path: "/login", + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (context, state) => SpotubePage( + child: kIsMobile ? const WebViewLogin() : const DesktopLoginPage(), ), - ], - ), - GoRoute( - path: "/mini-player", - parentNavigatorKey: rootNavigatorKey, - pageBuilder: (context, state) => SpotubePage( - child: MiniLyricsPage(prevSize: state.extra as Size), ), - ), - GoRoute( - path: "/login", - parentNavigatorKey: rootNavigatorKey, - pageBuilder: (context, state) => SpotubePage( - child: kIsMobile ? const WebViewLogin() : const DesktopLoginPage(), + GoRoute( + path: "/login-tutorial", + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (context, state) => const SpotubePage( + child: LoginTutorial(), + ), ), - ), - GoRoute( - path: "/login-tutorial", - parentNavigatorKey: rootNavigatorKey, - pageBuilder: (context, state) => const SpotubePage( - child: LoginTutorial(), + GoRoute( + path: "/lastfm-login", + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (context, state) => + const SpotubePage(child: LastFMLoginPage()), ), - ), - GoRoute( - path: "/lastfm-login", - parentNavigatorKey: rootNavigatorKey, - pageBuilder: (context, state) => - const SpotubePage(child: LastFMLoginPage()), - ), - ], -); + ], + ); +}); diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index c00643ce3..6cf920851 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -112,4 +112,7 @@ abstract class SpotubeIcons { static const discord = SimpleIcons.discord; static const youtube = SimpleIcons.youtube; static const radio = FeatherIcons.radio; + static const github = SimpleIcons.github; + static const openCollective = SimpleIcons.opencollective; + static const anonymous = FeatherIcons.user; } diff --git a/lib/components/getting_started/blur_card.dart b/lib/components/getting_started/blur_card.dart new file mode 100644 index 000000000..db8870136 --- /dev/null +++ b/lib/components/getting_started/blur_card.dart @@ -0,0 +1,31 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class BlurCard extends HookConsumerWidget { + final Widget child; + const BlurCard({super.key, required this.child}); + + @override + Widget build(BuildContext context, ref) { + return Container( + margin: const EdgeInsets.all(16.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + ), + constraints: const BoxConstraints(maxWidth: 400), + clipBehavior: Clip.antiAlias, + child: SizedBox( + width: double.infinity, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: child, + ), + ), + ), + ); + } +} diff --git a/lib/hooks/configurators/use_deep_linking.dart b/lib/hooks/configurators/use_deep_linking.dart index 3b7ec3f31..f11a1cff1 100644 --- a/lib/hooks/configurators/use_deep_linking.dart +++ b/lib/hooks/configurators/use_deep_linking.dart @@ -19,6 +19,8 @@ void useDeepLinking(WidgetRef ref) { final spotify = ref.watch(spotifyProvider); final queryClient = useQueryClient(); + final router = ref.watch(routerProvider); + useEffect(() { void uriListener(List files) async { for (final file in files) { diff --git a/lib/main.dart b/lib/main.dart index b6afa85ce..31c1da579 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -29,6 +29,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/cli/cli.dart'; import 'package:spotube/services/connectivity_adapter.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/themes/theme.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:system_theme/system_theme.dart'; @@ -68,6 +69,9 @@ Future main(List rawArgs) async { DiscordRPC.initialize(); } + await KVStoreService.initialize(); + KVStoreService.doneGettingStarted = false; + final hiveCacheDir = kIsWeb ? null : (await getApplicationSupportDirectory()).path; @@ -184,6 +188,7 @@ class SpotubeState extends ConsumerState { final locale = ref.watch(userPreferencesProvider.select((s) => s.locale)); final paletteColor = ref.watch(paletteProvider.select((s) => s?.dominantColor?.color)); + final router = ref.watch(routerProvider); useDisableBatteryOptimizations(); useInitSysTray(ref); diff --git a/lib/pages/getting_started/getting_started.dart b/lib/pages/getting_started/getting_started.dart new file mode 100644 index 000000000..47cfda032 --- /dev/null +++ b/lib/pages/getting_started/getting_started.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/pages/getting_started/sections/greeting.dart'; +import 'package:spotube/pages/getting_started/sections/playback.dart'; +import 'package:spotube/pages/getting_started/sections/region.dart'; +import 'package:spotube/pages/getting_started/sections/support.dart'; + +class GettingStarting extends HookConsumerWidget { + const GettingStarting({super.key}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:colorScheme) = Theme.of(context); + final pageController = usePageController(); + + final onNext = useCallback(() { + pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }, [pageController]); + + final onPrevious = useCallback(() { + pageController.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }, [pageController]); + + return Scaffold( + appBar: PageWindowTitleBar( + backgroundColor: Colors.transparent, + actions: [ + ListenableBuilder( + listenable: pageController, + builder: (context, _) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: pageController.hasClients && + (pageController.page == 0 || pageController.page == 3) + ? const SizedBox() + : TextButton( + onPressed: () { + pageController.animateToPage( + 3, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }, + child: Text( + "Skip this nonsense", + style: TextStyle( + decoration: TextDecoration.underline, + decorationColor: colorScheme.primary, + ), + ), + ), + ); + }, + ), + ], + ), + extendBodyBehindAppBar: true, + body: DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: Assets.bengaliPatternsBg.provider(), + fit: BoxFit.cover, + colorFilter: ColorFilter.mode( + colorScheme.background.withOpacity(0.2), + BlendMode.srcOver, + ), + ), + ), + child: PageView( + controller: pageController, + children: [ + GettingStartedPageGreetingSection(onNext: onNext), + GettingStartedPageLanguageRegionSection(onNext: onNext), + GettingStartedPagePlaybackSection( + onNext: onNext, + onPrevious: onPrevious, + ), + const GettingStartedScreenSupportSection(), + ], + ), + ), + ); + } +} diff --git a/lib/pages/getting_started/sections/greeting.dart b/lib/pages/getting_started/sections/greeting.dart new file mode 100644 index 000000000..459500e35 --- /dev/null +++ b/lib/pages/getting_started/sections/greeting.dart @@ -0,0 +1,54 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/getting_started/blur_card.dart'; +import 'package:spotube/utils/platform.dart'; + +class GettingStartedPageGreetingSection extends HookConsumerWidget { + final VoidCallback onNext; + const GettingStartedPageGreetingSection({super.key, required this.onNext}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:textTheme) = Theme.of(context); + + return Center( + child: BlurCard( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Assets.spotubeLogoPng.image(height: 200), + const Gap(24), + Text( + "Spotube", + style: + textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const Gap(4), + Text( + "“Freedom of music${kIsMobile ? "in the palm of your hands" : ""}”", + textAlign: TextAlign.center, + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w300, + fontStyle: FontStyle.italic, + ), + ), + const Gap(84), + Directionality( + textDirection: TextDirection.rtl, + child: FilledButton.icon( + onPressed: onNext, + icon: const Icon(SpotubeIcons.angleRight), + label: const Text("Let's get started"), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/getting_started/sections/playback.dart b/lib/pages/getting_started/sections/playback.dart new file mode 100644 index 000000000..03d11f9bb --- /dev/null +++ b/lib/pages/getting_started/sections/playback.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/getting_started/blur_card.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; + +final audioSourceToIconMap = { + AudioSource.youtube: const Icon( + SpotubeIcons.youtube, + color: Colors.red, + size: 30, + ), + AudioSource.piped: const Icon(SpotubeIcons.piped, size: 30), + AudioSource.jiosaavn: Assets.jiosaavn.image(width: 48, height: 48), +}; + +final audioSourceToDescription = { + AudioSource.youtube: + "Recommended and works best.\nHighest quality: 148kbps mp4, 128kbps opus", + AudioSource.piped: "Feeling free? Same as YouTube but a lot free", + AudioSource.jiosaavn: + "Best for South Asian region.\nHighest quality: 320kbps mp4", +}; + +class GettingStartedPagePlaybackSection extends HookConsumerWidget { + final VoidCallback onNext; + final VoidCallback onPrevious; + + const GettingStartedPagePlaybackSection({ + super.key, + required this.onNext, + required this.onPrevious, + }); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:textTheme, :colorScheme, :dividerColor) = + Theme.of(context); + final preferences = ref.watch(userPreferencesProvider); + final preferencesNotifier = ref.read(userPreferencesProvider.notifier); + + return Center( + child: BlurCard( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Icon(SpotubeIcons.album, size: 16), + const Gap(8), + Text(context.l10n.playback, style: textTheme.titleMedium), + ], + ), + const Gap(16), + ListTile( + title: Text("Select Audio Source", style: textTheme.titleMedium), + ), + const Gap(16), + ToggleButtons( + isSelected: [ + for (final source in AudioSource.values) + preferences.audioSource == source, + ], + onPressed: (index) { + preferencesNotifier.setAudioSource(AudioSource.values[index]); + }, + borderRadius: BorderRadius.circular(8), + children: [ + for (final source in AudioSource.values) + SizedBox.square( + dimension: 84, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + audioSourceToIconMap[source]!, + const Gap(8), + Text( + source.name, + style: textTheme.bodySmall!.copyWith( + color: preferences.audioSource == source + ? colorScheme.primary + : null, + ), + ), + ], + ), + ), + ], + ), + ListTile( + title: Align( + alignment: switch (preferences.audioSource) { + AudioSource.youtube => Alignment.centerLeft, + AudioSource.piped => Alignment.center, + AudioSource.jiosaavn => Alignment.centerRight, + }, + child: Text( + audioSourceToDescription[preferences.audioSource]!, + style: textTheme.bodySmall?.copyWith( + color: dividerColor, + ), + ), + ), + ), + const Gap(16), + ListTile( + title: Text(context.l10n.endless_playback), + subtitle: Text( + "Automatically append new songs\nto the end of the queue", + style: textTheme.bodySmall?.copyWith( + color: dividerColor, + ), + ), + onTap: () { + preferencesNotifier + .setEndlessPlayback(!preferences.endlessPlayback); + }, + trailing: Switch( + value: preferences.endlessPlayback, + onChanged: (value) { + preferencesNotifier.setEndlessPlayback(value); + }, + ), + ), + const Gap(34), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + FilledButton.icon( + icon: const Icon(SpotubeIcons.angleLeft), + label: Text(context.l10n.previous), + onPressed: onPrevious, + ), + Directionality( + textDirection: TextDirection.rtl, + child: FilledButton.icon( + icon: const Icon(SpotubeIcons.angleRight), + label: Text(context.l10n.next), + onPressed: onNext, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/getting_started/sections/region.dart b/lib/pages/getting_started/sections/region.dart new file mode 100644 index 000000000..23885d50d --- /dev/null +++ b/lib/pages/getting_started/sections/region.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/language_codes.dart'; +import 'package:spotube/collections/spotify_markets.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/getting_started/blur_card.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/l10n/l10n.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; + +class GettingStartedPageLanguageRegionSection extends HookConsumerWidget { + final void Function() onNext; + const GettingStartedPageLanguageRegionSection( + {super.key, required this.onNext}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:textTheme, :dividerColor) = Theme.of(context); + final preferences = ref.watch(userPreferencesProvider); + + return SafeArea( + child: Center( + child: BlurCard( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Icon( + SpotubeIcons.language, + size: 16, + ), + const SizedBox(width: 8), + Text( + "Language and Region", + style: textTheme.titleMedium, + ), + ], + ), + const Gap(48), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Choose your region", + style: textTheme.titleSmall, + ), + Text( + "This will help us show you the right content\nfor your location.", + style: textTheme.bodySmall?.copyWith( + color: dividerColor, + ), + ), + const Gap(16), + DropdownMenu( + initialSelection: preferences.recommendationMarket, + onSelected: (value) { + if (value == null) return; + ref + .read(userPreferencesProvider.notifier) + .setRecommendationMarket(value); + }, + hintText: preferences.recommendationMarket.name, + label: Text(context.l10n.market_place_region), + inputDecorationTheme: + const InputDecorationTheme(isDense: true), + dropdownMenuEntries: [ + for (final market in spotifyMarkets) + DropdownMenuEntry( + value: market.$1, + label: market.$2, + ), + ], + ), + const Gap(36), + Text( + "Choose your language", + style: textTheme.titleSmall, + ), + const Gap(16), + DropdownMenu( + initialSelection: preferences.locale, + onSelected: (locale) { + if (locale == null) return; + ref + .read(userPreferencesProvider.notifier) + .setLocale(locale); + }, + hintText: context.l10n.system_default, + label: Text(context.l10n.language), + inputDecorationTheme: + const InputDecorationTheme(isDense: true), + dropdownMenuEntries: [ + DropdownMenuEntry( + value: const Locale("system", "system"), + label: context.l10n.system_default, + ), + for (final locale in L10n.all) + DropdownMenuEntry( + value: locale, + label: LanguageLocals.getDisplayLanguage( + locale.languageCode) + .toString(), + ), + ], + ), + ], + ), + const Gap(48), + Align( + alignment: Alignment.centerRight, + child: Directionality( + textDirection: TextDirection.rtl, + child: FilledButton.icon( + icon: const Icon(SpotubeIcons.angleRight), + label: Text(context.l10n.next), + onPressed: onNext, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/getting_started/sections/support.dart b/lib/pages/getting_started/sections/support.dart new file mode 100644 index 000000000..9b798f566 --- /dev/null +++ b/lib/pages/getting_started/sections/support.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/getting_started/blur_card.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class GettingStartedScreenSupportSection extends HookConsumerWidget { + const GettingStartedScreenSupportSection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:textTheme, :colorScheme) = Theme.of(context); + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + BlurCard( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(SpotubeIcons.heartFilled, color: Colors.pink), + const SizedBox(width: 8), + Text( + "Help this project grow", + style: + textTheme.titleMedium?.copyWith(color: Colors.pink), + ), + ], + ), + const Gap(16), + const Text( + "Spotube is an open-source project. You can help this project grow by contributing to the project, reporting bugs, or suggesting new features.", + ), + const Gap(16), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton.icon( + icon: const Icon(SpotubeIcons.github), + label: const Text("Contribute on GitHub"), + style: FilledButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () async { + await launchUrlString( + "https://github.com/KRTirtho/spotube", + mode: LaunchMode.externalApplication, + ); + }, + ), + const Gap(16), + FilledButton.icon( + icon: const Icon(SpotubeIcons.openCollective), + label: const Text("Donate on Open Collective"), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xff4cb7f6), + foregroundColor: Colors.white, + ), + onPressed: () async { + await launchUrlString( + "https://opencollective.com/spotube", + mode: LaunchMode.externalApplication, + ); + }, + ), + ], + ), + ], + ), + ), + const Gap(48), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 250), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + gradient: LinearGradient( + colors: [ + colorScheme.primary, + colorScheme.secondary, + ], + ), + ), + child: TextButton.icon( + icon: const Icon(SpotubeIcons.anonymous), + label: const Text("Browse anonymously"), + style: TextButton.styleFrom( + foregroundColor: Colors.white, + ), + onPressed: () { + KVStoreService.doneGettingStarted = true; + context.go("/"); + }, + ), + ), + const Gap(16), + FilledButton.icon( + icon: const Icon(SpotubeIcons.spotify), + label: const Text("Connect Spotify Account"), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xff1db954), + foregroundColor: Colors.white, + ), + onPressed: () { + KVStoreService.doneGettingStarted = true; + context.push("/login"); + }, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/settings/sections/appearance.dart b/lib/pages/settings/sections/appearance.dart index 5de36c637..3d9412125 100644 --- a/lib/pages/settings/sections/appearance.dart +++ b/lib/pages/settings/sections/appearance.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; @@ -10,7 +11,11 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; class SettingsAppearanceSection extends HookConsumerWidget { - const SettingsAppearanceSection({Key? key}) : super(key: key); + final bool isGettingStarted; + const SettingsAppearanceSection({ + Key? key, + this.isGettingStarted = false, + }) : super(key: key); @override Widget build(BuildContext context, ref) { @@ -24,87 +29,101 @@ class SettingsAppearanceSection extends HookConsumerWidget { }); }, []); - return SectionCardWithHeading( - heading: context.l10n.appearance, - children: [ - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.dashboard), - title: Text(context.l10n.layout_mode), - subtitle: Text(context.l10n.override_layout_settings), - value: preferences.layoutMode, - onChanged: (value) { - if (value != null) { - preferencesNotifier.setLayoutMode(value); - } - }, - options: [ - DropdownMenuItem( - value: LayoutMode.adaptive, - child: Text(context.l10n.adaptive), - ), - DropdownMenuItem( - value: LayoutMode.compact, - child: Text(context.l10n.compact), - ), - DropdownMenuItem( - value: LayoutMode.extended, - child: Text(context.l10n.extended), - ), - ], - ), - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.darkMode), - title: Text(context.l10n.theme), - value: preferences.themeMode, - options: [ - DropdownMenuItem( - value: ThemeMode.dark, - child: Text(context.l10n.dark), - ), - DropdownMenuItem( - value: ThemeMode.light, - child: Text(context.l10n.light), - ), - DropdownMenuItem( - value: ThemeMode.system, - child: Text(context.l10n.system), - ), - ], - onChanged: (value) { - if (value != null) { - preferencesNotifier.setThemeMode(value); - } - }, - ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.amoled), - title: Text(context.l10n.use_amoled_mode), - subtitle: Text(context.l10n.pitch_dark_theme), - value: preferences.amoledDarkTheme, - onChanged: preferencesNotifier.setAmoledDarkTheme, - ), - ListTile( - leading: const Icon(SpotubeIcons.palette), - title: Text(context.l10n.accent_color), - contentPadding: const EdgeInsets.symmetric( - horizontal: 15, - vertical: 5, + final children = [ + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.dashboard), + title: Text(context.l10n.layout_mode), + subtitle: Text(context.l10n.override_layout_settings), + value: preferences.layoutMode, + onChanged: (value) { + if (value != null) { + preferencesNotifier.setLayoutMode(value); + } + }, + options: [ + DropdownMenuItem( + value: LayoutMode.adaptive, + child: Text(context.l10n.adaptive), + ), + DropdownMenuItem( + value: LayoutMode.compact, + child: Text(context.l10n.compact), ), - trailing: ColorTile.compact( - color: preferences.accentColorScheme, - onPressed: pickColorScheme(), - isActive: true, + DropdownMenuItem( + value: LayoutMode.extended, + child: Text(context.l10n.extended), ), - onTap: pickColorScheme(), + ], + ), + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.darkMode), + title: Text(context.l10n.theme), + value: preferences.themeMode, + options: [ + DropdownMenuItem( + value: ThemeMode.dark, + child: Text(context.l10n.dark), + ), + DropdownMenuItem( + value: ThemeMode.light, + child: Text(context.l10n.light), + ), + DropdownMenuItem( + value: ThemeMode.system, + child: Text(context.l10n.system), + ), + ], + onChanged: (value) { + if (value != null) { + preferencesNotifier.setThemeMode(value); + } + }, + ), + SwitchListTile( + secondary: const Icon(SpotubeIcons.amoled), + title: Text(context.l10n.use_amoled_mode), + subtitle: Text(context.l10n.pitch_dark_theme), + value: preferences.amoledDarkTheme, + onChanged: preferencesNotifier.setAmoledDarkTheme, + ), + ListTile( + leading: const Icon(SpotubeIcons.palette), + title: Text(context.l10n.accent_color), + contentPadding: const EdgeInsets.symmetric( + horizontal: 15, + vertical: 5, ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.colorSync), - title: Text(context.l10n.sync_album_color), - subtitle: Text(context.l10n.sync_album_color_description), - value: preferences.albumColorSync, - onChanged: preferencesNotifier.setAlbumColorSync, + trailing: ColorTile.compact( + color: preferences.accentColorScheme, + onPressed: pickColorScheme(), + isActive: true, ), - ], + onTap: pickColorScheme(), + ), + SwitchListTile( + secondary: const Icon(SpotubeIcons.colorSync), + title: Text(context.l10n.sync_album_color), + subtitle: Text(context.l10n.sync_album_color_description), + value: preferences.albumColorSync, + onChanged: preferencesNotifier.setAlbumColorSync, + ), + ]; + + if (isGettingStarted) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final child in children) ...[ + child, + const Gap(16), + ], + ], + ); + } + + return SectionCardWithHeading( + heading: context.l10n.appearance, + children: children, ); } } diff --git a/lib/services/kv_store/kv_store.dart b/lib/services/kv_store/kv_store.dart new file mode 100644 index 000000000..6f6807e02 --- /dev/null +++ b/lib/services/kv_store/kv_store.dart @@ -0,0 +1,15 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +abstract class KVStoreService { + static SharedPreferences? _sharedPreferences; + static SharedPreferences get sharedPreferences => _sharedPreferences!; + + static Future initialize() async { + _sharedPreferences = await SharedPreferences.getInstance(); + } + + static bool get doneGettingStarted => + sharedPreferences.getBool('doneGettingStarted') ?? false; + static set doneGettingStarted(bool value) => + sharedPreferences.setBool('doneGettingStarted', value); +} diff --git a/lib/utils/persisted_state_notifier.dart b/lib/utils/persisted_state_notifier.dart index 218cd64a2..60f7b96e0 100644 --- a/lib/utils/persisted_state_notifier.dart +++ b/lib/utils/persisted_state_notifier.dart @@ -119,7 +119,9 @@ abstract class PersistedStateNotifier extends StateNotifier { Future _load() async { final json = await box.get(cacheKey); - if (json != null) { + if (json != null || + (json is Map && json.entries.isNotEmpty) || + (json is List && json.isNotEmpty)) { state = await fromJson(castNestedJson(json)); } }