feat: add getting started page

This commit is contained in:
Kingkor Roy Tirtho 2024-02-25 22:01:38 +06:00
parent ca71406505
commit 96a2a1f5a6
15 changed files with 896 additions and 225 deletions

View File

@ -4,8 +4,8 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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/collections/routes.dart';
import 'package:spotube/components/player/player_controls.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
@ -64,6 +64,7 @@ class HomeTabIntent extends Intent {
class HomeTabAction extends Action<HomeTabIntent> { class HomeTabAction extends Action<HomeTabIntent> {
@override @override
invoke(intent) { invoke(intent) {
final router = intent.ref.read(routerProvider);
switch (intent.tab) { switch (intent.tab) {
case HomeTabs.browse: case HomeTabs.browse:
router.go("/"); router.go("/");

View File

@ -6,6 +6,11 @@ class ISOLanguageName {
required this.name, required this.name,
required this.nativeName, required this.nativeName,
}); });
@override
String toString() {
return "$name ($nativeName)";
}
} }
// Uncomment the languages as we add support for them // Uncomment the languages as we add support for them

View File

@ -2,8 +2,10 @@ import 'package:catcher_2/catcher_2.dart';
import 'package:flutter/foundation.dart' hide Category; import 'package:flutter/foundation.dart' hide Category;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart' hide Search; import 'package:spotify/spotify.dart' hide Search;
import 'package:spotube/pages/album/album.dart'; 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/genre_playlists.dart';
import 'package:spotube/pages/home/genres/genres.dart'; import 'package:spotube/pages/home/genres/genres.dart';
import 'package:spotube/pages/home/home.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/about.dart';
import 'package:spotube/pages/settings/logs.dart'; import 'package:spotube/pages/settings/logs.dart';
import 'package:spotube/pages/track/track.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/utils/platform.dart';
import 'package:spotube/components/shared/spotube_page_route.dart'; import 'package:spotube/components/shared/spotube_page_route.dart';
import 'package:spotube/pages/artist/artist.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 rootNavigatorKey = Catcher2.navigatorKey;
final shellRouteNavigatorKey = GlobalKey<NavigatorState>(); final shellRouteNavigatorKey = GlobalKey<NavigatorState>();
final router = GoRouter( final routerProvider = Provider((ref) {
navigatorKey: rootNavigatorKey, return GoRouter(
routes: [ navigatorKey: rootNavigatorKey,
ShellRoute( routes: [
navigatorKey: shellRouteNavigatorKey, ShellRoute(
builder: (context, state, child) => RootApp(child: child), navigatorKey: shellRouteNavigatorKey,
routes: [ builder: (context, state, child) => RootApp(child: child),
GoRoute( routes: [
path: "/", GoRoute(
pageBuilder: (context, state) => const SpotubePage(child: HomePage()), path: "/",
routes: [ redirect: (context, state) async {
GoRoute( final authNotifier =
path: "genres", ref.read(AuthenticationNotifier.provider.notifier);
pageBuilder: (context, state) => final json = await authNotifier.box.get(authNotifier.cacheKey);
const SpotubePage(child: GenrePage()),
), if (json["cookie"] == null &&
GoRoute( !KVStoreService.doneGettingStarted) {
path: "genre/:categoryId", return "/getting-started";
pageBuilder: (context, state) => SpotubePage( }
child: GenrePlaylistsPage(
category: state.extra as Category, return null;
), },
),
),
],
),
GoRoute(
path: "/search",
name: "Search",
pageBuilder: (context, state) =>
const SpotubePage(child: SearchPage()),
),
GoRoute(
path: "/library",
name: "Library",
pageBuilder: (context, state) => pageBuilder: (context, state) =>
const SpotubePage(child: LibraryPage()), const SpotubePage(child: HomePage()),
routes: [ routes: [
GoRoute( GoRoute(
path: "generate", path: "genres",
pageBuilder: (context, state) => pageBuilder: (context, state) =>
const SpotubePage(child: PlaylistGeneratorPage()), const SpotubePage(child: GenrePage()),
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(),
),
routes: [
GoRoute(
path: "blacklist",
pageBuilder: (context, state) => SpotubeSlidePage(
child: const BlackListPage(),
), ),
),
if (!kIsWeb)
GoRoute( GoRoute(
path: "logs", path: "genre/:categoryId",
pageBuilder: (context, state) => SpotubeSlidePage( pageBuilder: (context, state) => SpotubePage(
child: const LogsPage(), child: GenrePlaylistsPage(
category: state.extra as Category,
),
), ),
), ),
GoRoute( ],
path: "about", ),
pageBuilder: (context, state) => SpotubeSlidePage( GoRoute(
child: const AboutSpotube(), 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(),
), ),
], routes: [
), GoRoute(
GoRoute( path: "blacklist",
path: "/album/:id", pageBuilder: (context, state) => SpotubeSlidePage(
pageBuilder: (context, state) { child: const BlackListPage(),
assert(state.extra is AlbumSimple); ),
return SpotubePage( ),
child: AlbumPage(album: state.extra as AlbumSimple), if (!kIsWeb)
); GoRoute(
}, path: "logs",
), pageBuilder: (context, state) => SpotubeSlidePage(
GoRoute( child: const LogsPage(),
path: "/artist/:id", ),
pageBuilder: (context, state) { ),
assert(state.pathParameters["id"] != null); GoRoute(
return SpotubePage(child: ArtistPage(state.pathParameters["id"]!)); path: "about",
}, pageBuilder: (context, state) => SpotubeSlidePage(
), child: const AboutSpotube(),
GoRoute( ),
path: "/playlist/:id", ),
pageBuilder: (context, state) { ],
assert(state.extra is PlaylistSimple); ),
return SpotubePage( GoRoute(
child: state.pathParameters["id"] == "user-liked-tracks" path: "/album/:id",
? LikedPlaylistPage(playlist: state.extra as PlaylistSimple) pageBuilder: (context, state) {
: PlaylistPage(playlist: state.extra as PlaylistSimple), assert(state.extra is AlbumSimple);
); return SpotubePage(
}, child: AlbumPage(album: state.extra as AlbumSimple),
), );
GoRoute( },
path: "/track/:id", ),
pageBuilder: (context, state) { GoRoute(
final id = state.pathParameters["id"]!; path: "/artist/:id",
return SpotubePage( pageBuilder: (context, state) {
child: TrackPage(trackId: id), assert(state.pathParameters["id"] != null);
); return SpotubePage(
}, child: ArtistPage(state.pathParameters["id"]!));
), },
], ),
), GoRoute(
GoRoute( path: "/playlist/:id",
path: "/mini-player", pageBuilder: (context, state) {
parentNavigatorKey: rootNavigatorKey, assert(state.extra is PlaylistSimple);
pageBuilder: (context, state) => SpotubePage( return SpotubePage(
child: MiniLyricsPage(prevSize: state.extra as Size), 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(
GoRoute( path: "/mini-player",
path: "/login", parentNavigatorKey: rootNavigatorKey,
parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => SpotubePage(
pageBuilder: (context, state) => SpotubePage( child: MiniLyricsPage(prevSize: state.extra as Size),
child: kIsMobile ? const WebViewLogin() : const DesktopLoginPage(), ),
), ),
), GoRoute(
GoRoute( path: "/getting-started",
path: "/login-tutorial", parentNavigatorKey: rootNavigatorKey,
parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => const SpotubePage(
pageBuilder: (context, state) => const SpotubePage( child: GettingStarting(),
child: LoginTutorial(), ),
), ),
), GoRoute(
GoRoute( path: "/login",
path: "/lastfm-login", parentNavigatorKey: rootNavigatorKey,
parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => SpotubePage(
pageBuilder: (context, state) => child: kIsMobile ? const WebViewLogin() : const DesktopLoginPage(),
const SpotubePage(child: LastFMLoginPage()), ),
), ),
], 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()),
),
],
);
});

View File

@ -112,4 +112,7 @@ abstract class SpotubeIcons {
static const discord = SimpleIcons.discord; static const discord = SimpleIcons.discord;
static const youtube = SimpleIcons.youtube; static const youtube = SimpleIcons.youtube;
static const radio = FeatherIcons.radio; static const radio = FeatherIcons.radio;
static const github = SimpleIcons.github;
static const openCollective = SimpleIcons.opencollective;
static const anonymous = FeatherIcons.user;
} }

View File

@ -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,
),
),
),
);
}
}

View File

@ -19,6 +19,8 @@ void useDeepLinking(WidgetRef ref) {
final spotify = ref.watch(spotifyProvider); final spotify = ref.watch(spotifyProvider);
final queryClient = useQueryClient(); final queryClient = useQueryClient();
final router = ref.watch(routerProvider);
useEffect(() { useEffect(() {
void uriListener(List<SharedFile> files) async { void uriListener(List<SharedFile> files) async {
for (final file in files) { for (final file in files) {

View File

@ -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/audio_player/audio_player.dart';
import 'package:spotube/services/cli/cli.dart'; import 'package:spotube/services/cli/cli.dart';
import 'package:spotube/services/connectivity_adapter.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/themes/theme.dart';
import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/persisted_state_notifier.dart';
import 'package:system_theme/system_theme.dart'; import 'package:system_theme/system_theme.dart';
@ -68,6 +69,9 @@ Future<void> main(List<String> rawArgs) async {
DiscordRPC.initialize(); DiscordRPC.initialize();
} }
await KVStoreService.initialize();
KVStoreService.doneGettingStarted = false;
final hiveCacheDir = final hiveCacheDir =
kIsWeb ? null : (await getApplicationSupportDirectory()).path; kIsWeb ? null : (await getApplicationSupportDirectory()).path;
@ -184,6 +188,7 @@ class SpotubeState extends ConsumerState<Spotube> {
final locale = ref.watch(userPreferencesProvider.select((s) => s.locale)); final locale = ref.watch(userPreferencesProvider.select((s) => s.locale));
final paletteColor = final paletteColor =
ref.watch(paletteProvider.select((s) => s?.dominantColor?.color)); ref.watch(paletteProvider.select((s) => s?.dominantColor?.color));
final router = ref.watch(routerProvider);
useDisableBatteryOptimizations(); useDisableBatteryOptimizations();
useInitSysTray(ref); useInitSysTray(ref);

View File

@ -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(),
],
),
),
);
}
}

View File

@ -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"),
),
),
],
),
),
);
}
}

View File

@ -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,
),
),
],
),
],
),
),
);
}
}

View File

@ -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,
),
),
),
],
),
),
),
);
}
}

View File

@ -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");
},
),
],
),
),
],
),
);
}
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/settings/color_scheme_picker_dialog.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'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
class SettingsAppearanceSection extends HookConsumerWidget { 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 @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
@ -24,87 +29,101 @@ class SettingsAppearanceSection extends HookConsumerWidget {
}); });
}, []); }, []);
final children = [
AdaptiveSelectTile<LayoutMode>(
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<ThemeMode>(
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,
),
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( return SectionCardWithHeading(
heading: context.l10n.appearance, heading: context.l10n.appearance,
children: [ children: children,
AdaptiveSelectTile<LayoutMode>(
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<ThemeMode>(
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,
),
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,
),
],
); );
} }
} }

View File

@ -0,0 +1,15 @@
import 'package:shared_preferences/shared_preferences.dart';
abstract class KVStoreService {
static SharedPreferences? _sharedPreferences;
static SharedPreferences get sharedPreferences => _sharedPreferences!;
static Future<void> initialize() async {
_sharedPreferences = await SharedPreferences.getInstance();
}
static bool get doneGettingStarted =>
sharedPreferences.getBool('doneGettingStarted') ?? false;
static set doneGettingStarted(bool value) =>
sharedPreferences.setBool('doneGettingStarted', value);
}

View File

@ -119,7 +119,9 @@ abstract class PersistedStateNotifier<T> extends StateNotifier<T> {
Future<void> _load() async { Future<void> _load() async {
final json = await box.get(cacheKey); 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)); state = await fromJson(castNestedJson(json));
} }
} }