diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 59fc0f08..1f0a5e62 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -1,58 +1,64 @@ - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Sptube - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - spotube - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSAllowsArbitraryLoadsForMedia - - - CADisableMinimumFrameDurationOnPhone - - UIStatusBarHidden - - - + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Sptube + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + spotube + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsForMedia + + + CADisableMinimumFrameDurationOnPhone + + UIStatusBarHidden + + NSPhotoLibraryUsageDescription + This app require access to the photo library + NSCameraUsageDescription + This app require access to the device camera + NSMicrophoneUsageDescription + This app does not require access to the device microphone + + \ No newline at end of file diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 5503ebb3..7b5221b5 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -94,4 +94,6 @@ abstract class SpotubeIcons { static const noWifi = FeatherIcons.wifiOff; static const wifi = FeatherIcons.wifi; static const window = Icons.window_rounded; + static const user = FeatherIcons.user; + static const edit = FeatherIcons.edit; } diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index 3fb30952..0438e559 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -32,6 +32,7 @@ class PlaylistCard extends HookConsumerWidget { final updating = useState(false); final spotify = ref.watch(spotifyProvider); + final me = useQueries.user.me(ref); return PlaybuttonCard( margin: const EdgeInsets.symmetric(horizontal: 10), @@ -44,6 +45,7 @@ class PlaylistCard extends HookConsumerWidget { isPlaying: isPlaylistPlaying, isLoading: (isPlaylistPlaying && playlistQueue.isFetching) || updating.value, + isOwner: playlist.owner?.id == me.data?.id && me.data?.id != null, onTap: () { ServiceUtils.push( context, diff --git a/lib/components/playlist/playlist_create_dialog.dart b/lib/components/playlist/playlist_create_dialog.dart index b7cee79d..fa960210 100644 --- a/lib/components/playlist/playlist_create_dialog.dart +++ b/lib/components/playlist/playlist_create_dialog.dart @@ -1,106 +1,228 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:form_validator/form_validator.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/services/mutations/mutations.dart'; +import 'package:spotube/services/mutations/playlist.dart'; +import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistCreateDialog extends HookConsumerWidget { /// Track ids to add to the playlist final List trackIds; - const PlaylistCreateDialog({ + final String? playlistId; + PlaylistCreateDialog({ Key? key, this.trackIds = const [], + this.playlistId, }) : super(key: key); + final formKey = GlobalKey(); + @override Widget build(BuildContext context, ref) { - final spotify = ref.watch(spotifyProvider); - final playlistName = useTextEditingController(); - final description = useTextEditingController(); - final public = useState(false); - final collaborative = useState(false); - final client = useQueryClient(); - final navigator = Navigator.of(context); + return ScaffoldMessenger( + child: Scaffold( + backgroundColor: Colors.transparent, + body: HookBuilder(builder: (context) { + final userPlaylists = useQueries.playlist.ofMine(ref); + final updatingPlaylist = useMemoized( + () => userPlaylists.pages + .expand((p) => p.items ?? []) + .firstWhereOrNull((playlist) => playlist.id == playlistId), + [ + userPlaylists.pages, + playlistId, + ], + ); - Future onCreate() async { - if (playlistName.text.isEmpty) return; - final me = await spotify.me.get(); - final playlist = await spotify.playlists.createPlaylist( - me.id!, - playlistName.text, - collaborative: collaborative.value, - public: public.value, - description: description.text, - ); - if (trackIds.isNotEmpty) { - await spotify.playlists.addTracks( - trackIds.map((id) => "spotify:track:$id").toList(), - playlist.id!, - ); - } - await client - .getQuery( - "current-user-playlists", - ) - ?.refresh(); - navigator.pop(playlist); - } + final playlistName = useTextEditingController( + text: updatingPlaylist?.name, + ); + final description = useTextEditingController( + text: updatingPlaylist?.description, + ); + final public = useState( + updatingPlaylist?.public ?? false, + ); + final collaborative = useState( + updatingPlaylist?.collaborative ?? false, + ); + final image = useState(null); - return AlertDialog( - title: Text(context.l10n.create_a_playlist), - actions: [ - OutlinedButton( - child: Text(context.l10n.cancel), - onPressed: () { - Navigator.pop(context); - }, - ), - FilledButton( - onPressed: onCreate, - child: Text(context.l10n.create), - ), - ], - content: Container( - width: MediaQuery.of(context).size.width, - constraints: const BoxConstraints(maxWidth: 500), - child: ListView( - shrinkWrap: true, - children: [ - TextField( - controller: playlistName, - decoration: InputDecoration( - hintText: context.l10n.name_of_playlist, - labelText: context.l10n.name_of_playlist, + final isUpdatingPlaylist = playlistId != null; + + final l10n = context.l10n; + final theme = Theme.of(context); + final scaffold = ScaffoldMessenger.of(context); + + final onError = useCallback((error) { + if (error is SpotifyError || error is SpotifyException) { + scaffold.showSnackBar( + SnackBar( + content: Text( + l10n.error(error.message ?? "Epic failure!"), + style: theme.textTheme.bodyMedium!.copyWith( + color: theme.colorScheme.onError, + ), + ), + backgroundColor: theme.colorScheme.error, + ), + ); + } + }, [scaffold, l10n, theme]); + + final playlistCreateMutation = useMutations.playlist.create( + ref, + trackIds: trackIds, + onData: (value) { + Navigator.pop(context); + }, + onError: onError, + ); + + final playlistUpdateMutation = useMutations.playlist.update( + ref, + playlistId: playlistId, + onData: (value) { + Navigator.pop(context); + }, + onError: onError, + ); + + Future onCreate() async { + if (!formKey.currentState!.validate()) return; + + final PlaylistCRUDVariables payload = ( + playlistName: playlistName.text, + collaborative: collaborative.value, + public: public.value, + description: description.text, + base64Image: image.value?.path != null + ? await image.value! + .readAsBytes() + .then((bytes) => base64Encode(bytes)) + : null, + ); + + if (isUpdatingPlaylist) { + await playlistUpdateMutation.mutate(payload); + } else { + await playlistCreateMutation.mutate(payload); + } + } + + return AlertDialog( + title: Text( + isUpdatingPlaylist + ? context.l10n.update_playlist + : context.l10n.create_a_playlist, + ), + actions: [ + OutlinedButton( + child: Text(context.l10n.cancel), + onPressed: () { + Navigator.pop(context); + }, + ), + FilledButton( + onPressed: onCreate, + child: Text( + isUpdatingPlaylist + ? context.l10n.update + : context.l10n.create, + ), + ), + ], + insetPadding: const EdgeInsets.all(8), + content: Container( + width: MediaQuery.of(context).size.width, + constraints: const BoxConstraints(maxWidth: 500), + child: Form( + key: formKey, + child: ListView( + shrinkWrap: true, + children: [ + Center( + child: Stack( + children: [ + UniversalImage( + path: image.value?.path ?? + TypeConversionUtils.image_X_UrlString( + updatingPlaylist?.images, + placeholder: ImagePlaceholder.collection, + ), + height: 200, + ), + Positioned( + bottom: 20, + right: 20, + child: IconButton.filled( + icon: const Icon(SpotubeIcons.edit), + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.surface, + foregroundColor: theme.colorScheme.primary, + elevation: 2, + shadowColor: theme.colorScheme.onSurface, + ), + onPressed: () async { + final imageFile = await ImagePicker() + .pickImage(source: ImageSource.gallery); + + image.value = imageFile ?? image.value; + }, + ), + ), + ], + ), + ), + const SizedBox(height: 10), + TextFormField( + controller: playlistName, + decoration: InputDecoration( + hintText: context.l10n.name_of_playlist, + labelText: context.l10n.name_of_playlist, + ), + validator: ValidationBuilder().required().build(), + ), + const SizedBox(height: 10), + TextFormField( + controller: description, + decoration: InputDecoration( + hintText: context.l10n.description, + ), + keyboardType: TextInputType.multiline, + maxLines: 5, + ), + const SizedBox(height: 10), + CheckboxListTile( + title: Text(context.l10n.public), + value: public.value, + onChanged: (val) => public.value = val ?? false, + ), + const SizedBox(height: 10), + CheckboxListTile( + title: Text(context.l10n.collaborative), + value: collaborative.value, + onChanged: (val) => collaborative.value = val ?? false, + ), + ], + ), ), ), - const SizedBox(height: 10), - TextField( - controller: description, - decoration: InputDecoration( - hintText: context.l10n.description, - ), - keyboardType: TextInputType.multiline, - maxLines: 5, - ), - const SizedBox(height: 10), - CheckboxListTile( - title: Text(context.l10n.public), - value: public.value, - onChanged: (val) => public.value = val ?? false, - ), - const SizedBox(height: 10), - CheckboxListTile( - title: Text(context.l10n.collaborative), - value: collaborative.value, - onChanged: (val) => collaborative.value = val ?? false, - ), - ], - ), + ); + }), ), ); } @@ -112,7 +234,7 @@ class PlaylistCreateDialogButton extends HookConsumerWidget { showPlaylistDialog(BuildContext context, SpotifyApi spotify) { showDialog( context: context, - builder: (context) => const PlaylistCreateDialog(), + builder: (context) => PlaylistCreateDialog(), ); } @@ -132,11 +254,12 @@ class PlaylistCreateDialogButton extends HookConsumerWidget { } return FilledButton.tonalIcon( - style: FilledButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.primary, - ), - icon: const Icon(SpotubeIcons.addFilled), - label: Text(context.l10n.create_playlist), - onPressed: () => showPlaylistDialog(context, spotify)); + style: FilledButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.primary, + ), + icon: const Icon(SpotubeIcons.addFilled), + label: Text(context.l10n.create_playlist), + onPressed: () => showPlaylistDialog(context, spotify), + ); } } diff --git a/lib/components/shared/heart_button.dart b/lib/components/shared/heart_button.dart index c18ca2e4..4a23cc48 100644 --- a/lib/components/shared/heart_button.dart +++ b/lib/components/shared/heart_button.dart @@ -159,10 +159,14 @@ class TrackHeartButton extends HookConsumerWidget { class PlaylistHeartButton extends HookConsumerWidget { final PlaylistSimple playlist; + final IconData? icon; + final ValueChanged? onData; const PlaylistHeartButton({ required this.playlist, Key? key, + this.icon, + this.onData, }) : super(key: key); @override @@ -181,6 +185,7 @@ class PlaylistHeartButton extends HookConsumerWidget { refreshQueries: [ isLikedQuery.key, ], + onData: onData, ); if (me.isLoading || !me.hasData) { @@ -193,6 +198,7 @@ class PlaylistHeartButton extends HookConsumerWidget { ? context.l10n.remove_from_favorites : context.l10n.save_as_favorite, color: Colors.white, + icon: icon, onPressed: isLikedQuery.hasData ? () { togglePlaylistLike.mutate(isLikedQuery.data!); diff --git a/lib/components/shared/playbutton_card.dart b/lib/components/shared/playbutton_card.dart index 86c3f046..c9daa267 100644 --- a/lib/components/shared/playbutton_card.dart +++ b/lib/components/shared/playbutton_card.dart @@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/hover_builder.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/hooks/use_breakpoint_value.dart'; import 'package:spotube/hooks/use_brightness_value.dart'; @@ -28,6 +29,7 @@ class PlaybuttonCard extends HookWidget { final bool isPlaying; final bool isLoading; final String title; + final bool isOwner; const PlaybuttonCard({ required this.imageUrl, @@ -39,6 +41,7 @@ class PlaybuttonCard extends HookWidget { this.onPlaybuttonPressed, this.onAddToQueuePressed, this.onTap, + this.isOwner = false, Key? key, }) : super(key: key); @@ -153,6 +156,42 @@ class PlaybuttonCard extends HookWidget { ), ), ), + if (isOwner) + Positioned( + top: 15, + left: 25, + child: AnimatedSize( + duration: const Duration(milliseconds: 150), + alignment: Alignment.centerLeft, + curve: Curves.easeInExpo, + child: HoverBuilder(builder: (context, isHovered) { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.blueAccent, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + SpotubeIcons.user, + color: Colors.white, + size: 16, + ), + if (isHovered) + Text( + "Owned by you", + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.white, + ), + ), + ], + ), + ); + }), + ), + ), AnimatedPositioned( duration: const Duration(milliseconds: 300), right: end, diff --git a/lib/components/shared/track_table/track_collection_view/track_collection_view.dart b/lib/components/shared/track_table/track_collection_view/track_collection_view.dart index b4a1314e..1c87d887 100644 --- a/lib/components/shared/track_table/track_collection_view/track_collection_view.dart +++ b/lib/components/shared/track_table/track_collection_view/track_collection_view.dart @@ -4,6 +4,7 @@ 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/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart'; @@ -71,6 +72,18 @@ class TrackCollectionView extends HookConsumerWidget { icon: const Icon(SpotubeIcons.share), onPressed: onShare, ), + if (isOwned) + IconButton( + icon: const Icon(SpotubeIcons.edit), + onPressed: () { + showDialog( + context: context, + builder: (context) { + return PlaylistCreateDialog(playlistId: id); + }, + ); + }, + ), if (heartBtn != null && auth != null) heartBtn!, IconButton( onPressed: playingState == PlayButtonState.playing diff --git a/lib/generated_plugin_registrant.dart b/lib/generated_plugin_registrant.dart index dc0cbb0b..a25a1f5f 100644 --- a/lib/generated_plugin_registrant.dart +++ b/lib/generated_plugin_registrant.dart @@ -8,7 +8,6 @@ import 'package:audio_service_web/audio_service_web.dart'; import 'package:audio_session/audio_session_web.dart'; -import 'package:file_picker/_internal/file_picker_web.dart'; import 'package:shared_preferences_web/shared_preferences_web.dart'; import 'package:url_launcher_web/url_launcher_web.dart'; @@ -18,7 +17,6 @@ import 'package:flutter_web_plugins/flutter_web_plugins.dart'; void registerPlugins(Registrar registrar) { AudioServiceWeb.registerWith(registrar); AudioSessionWeb.registerWith(registrar); - FilePickerWeb.registerWith(registrar); SharedPreferencesPlugin.registerWith(registrar); UrlLauncherPlugin.registerWith(registrar); registrar.registerMessageHandler(); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 74894612..bdfb7983 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -24,8 +24,10 @@ "liked_tracks_description": "All your liked tracks", "create_playlist": "Create Playlist", "create_a_playlist": "Create a playlist", + "update_playlist": "Update playlist", "create": "Create", "cancel": "Cancel", + "update": "update", "playlist_name": "Playlist Name", "name_of_playlist": "Name of the playlist", "description": "Description", diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index 722fcb6d..9c852ace 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -1,6 +1,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.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/shared/heart_button.dart'; import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart'; import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_view.dart'; @@ -18,34 +20,8 @@ import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistView extends HookConsumerWidget { final logger = getLogger(PlaylistView); - final PlaylistSimple playlist; - PlaylistView(this.playlist, {Key? key}) : super(key: key); - - Future playPlaylist( - List tracks, - WidgetRef ref, { - Track? currentTrack, - }) async { - final proxyPlaylist = ref.read(ProxyPlaylistNotifier.provider); - final playback = ref.read(ProxyPlaylistNotifier.notifier); - final sortBy = ref.read(trackCollectionSortState(playlist.id!)); - final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy); - currentTrack ??= sortedTracks.first; - final isPlaylistPlaying = proxyPlaylist.containsTracks(tracks); - if (!isPlaylistPlaying) { - playback.addCollection(playlist.id!); // for enabling loading indicator - await playback.load( - sortedTracks, - initialIndex: sortedTracks.indexWhere((s) => s.id == currentTrack?.id), - autoPlay: true, - ); - playback.addCollection(playlist.id!); - } else if (isPlaylistPlaying && - currentTrack.id != null && - currentTrack.id != proxyPlaylist.activeTrack?.id) { - await playback.jumpToTrack(currentTrack); - } - } + final PlaylistSimple playlistSimple; + PlaylistView(this.playlistSimple, {Key? key}) : super(key: key); @override Widget build(BuildContext context, ref) { @@ -55,6 +31,10 @@ class PlaylistView extends HookConsumerWidget { final mediaQuery = MediaQuery.of(context); final meSnapshot = useQueries.user.me(ref); + + final playlistQuery = useQueries.playlist.byId(ref, playlistSimple.id!); + final playlist = playlistQuery.data ?? playlistSimple; + final playlistTrackSnapshot = useQueries.playlist.tracksOfQuery(ref, playlist.id!); final likedTracksSnapshot = useQueries.playlist.likedTracksQuery(ref); @@ -83,6 +63,35 @@ class PlaylistView extends HookConsumerWidget { [proxyPlaylist.activeTrack, tracksSnapshot.data], ); + final playPlaylist = useCallback(( + List tracks, + WidgetRef ref, { + Track? currentTrack, + }) async { + final playback = ref.read(ProxyPlaylistNotifier.notifier); + final sortBy = ref.read(trackCollectionSortState(playlist.id!)); + final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy); + currentTrack ??= sortedTracks.first; + final isPlaylistPlaying = proxyPlaylist.containsTracks(tracks); + if (!isPlaylistPlaying) { + playback.addCollection(playlist.id!); // for enabling loading indicator + await playback.load( + sortedTracks, + initialIndex: + sortedTracks.indexWhere((s) => s.id == currentTrack?.id), + autoPlay: true, + ); + playback.addCollection(playlist.id!); + } else if (isPlaylistPlaying && + currentTrack.id != null && + currentTrack.id != proxyPlaylist.activeTrack?.id) { + await playback.jumpToTrack(currentTrack); + } + }, [proxyPlaylist, playlist]); + + final ownPlaylist = + playlist.owner?.id != null && playlist.owner?.id == meSnapshot.data?.id; + return TrackCollectionView( id: playlist.id!, playingState: isPlaylistPlaying && playlistTrackPlaying @@ -94,8 +103,7 @@ class PlaylistView extends HookConsumerWidget { titleImage: titleImage, tracksSnapshot: tracksSnapshot, description: playlist.description, - isOwned: playlist.owner?.id != null && - playlist.owner!.id == meSnapshot.data?.id, + isOwned: ownPlaylist, onPlay: ([track]) { if (tracksSnapshot.hasData) { if (!isPlaylistPlaying) { @@ -142,7 +150,13 @@ class PlaylistView extends HookConsumerWidget { ); }); }, - heartBtn: PlaylistHeartButton(playlist: playlist), + heartBtn: PlaylistHeartButton( + playlist: playlist, + icon: ownPlaylist ? SpotubeIcons.trash : null, + onData: (data) { + GoRouter.of(context).pop(); + }, + ), onShuffledPlay: ([track]) { final tracks = [...?tracksSnapshot.data]..shuffle(); diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index d4e1e9db..a6efdc8f 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -1,6 +1,6 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:collection/collection.dart'; -import 'package:file_picker/file_picker.dart'; +import 'package:file_selector/file_selector.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; @@ -11,7 +11,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:piped_client/piped_client.dart'; import 'package:spotube/collections/env.dart'; import 'package:spotube/collections/language_codes.dart'; - import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; import 'package:spotube/components/settings/section_card_with_heading.dart'; @@ -47,8 +46,8 @@ class SettingsPage extends HookConsumerWidget { }, []); final pickDownloadLocation = useCallback(() async { - final dirStr = await FilePicker.platform.getDirectoryPath( - dialogTitle: context.l10n.download_location, + final dirStr = await getDirectoryPath( + initialDirectory: preferences.downloadLocation, ); if (dirStr == null) return; preferences.setDownloadLocation(dirStr); diff --git a/lib/services/mutations/playlist.dart b/lib/services/mutations/playlist.dart index ee06ad9d..176b5cd8 100644 --- a/lib/services/mutations/playlist.dart +++ b/lib/services/mutations/playlist.dart @@ -1,7 +1,17 @@ import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; +import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/hooks/use_spotify_mutation.dart'; +import 'package:spotube/services/queries/queries.dart'; + +typedef PlaylistCRUDVariables = ({ + String playlistName, + bool? public, + bool? collaborative, + String? description, + String? base64Image, +}); class PlaylistMutations { const PlaylistMutations(); @@ -11,8 +21,8 @@ class PlaylistMutations { String playlistId, { List? refreshQueries, List? refreshInfiniteQueries, + ValueChanged? onData, }) { - final queryClient = useQueryClient(); return useSpotifyMutation( "toggle-playlist-like/$playlistId", (isLiked, spotify) async { @@ -25,10 +35,12 @@ class PlaylistMutations { }, ref: ref, refreshQueries: refreshQueries, - refreshInfiniteQueries: refreshInfiniteQueries, - onData: (data, recoveryData) async { - await queryClient - .refreshInfiniteQueryAllPages("current-user-playlists"); + refreshInfiniteQueries: [ + ...?refreshInfiniteQueries, + "current-user-playlists", + ], + onData: (data, recoveryData) { + onData?.call(data); }, ); } @@ -47,4 +59,91 @@ class PlaylistMutations { refreshQueries: ["playlist-tracks/$playlistId"], ); } + + Mutation create( + WidgetRef ref, { + List? trackIds, + ValueChanged? onError, + ValueChanged? onData, + }) { + final me = useQueries.user.me(ref); + return useSpotifyMutation( + "create-playlist", + (variable, spotify) async { + final playlist = await spotify.playlists.createPlaylist( + me.data!.id!, + variable.playlistName, + collaborative: variable.collaborative, + description: variable.description, + public: variable.public, + ); + + if (variable.base64Image != null) { + await spotify.playlists.updatePlaylistImage( + playlist.id!, + variable.base64Image!, + ); + } + + if (trackIds != null && trackIds.isNotEmpty) { + await spotify.playlists.addTracks( + trackIds.map((id) => "spotify:track:$id").toList(), + playlist.id!, + ); + } + + return playlist; + }, + refreshInfiniteQueries: [ + "current-user-playlists", + ], + ref: ref, + onError: (error, recoveryData) { + onError?.call(error); + }, + onData: (data, recoveryData) { + onData?.call(data); + }, + ); + } + + Mutation update( + WidgetRef ref, { + String? playlistId, + ValueChanged? onError, + ValueChanged? onData, + }) { + return useSpotifyMutation( + "update-playlist/$playlistId", + (variable, spotify) async { + if (playlistId == null) return; + await spotify.playlists.updatePlaylist( + playlistId, + variable.playlistName, + collaborative: variable.collaborative, + description: variable.description, + public: variable.public, + ); + if (variable.base64Image != null) { + await spotify.playlists.updatePlaylistImage( + playlistId, + variable.base64Image!, + ); + } + }, + refreshQueries: [ + "playlist/$playlistId", + ], + refreshInfiniteQueries: [ + "current-user-playlists", + ], + ref: ref, + onError: (error, recoveryData) { + onError?.call(error); + }, + onData: (data, recoveryData) { + onData?.call(data); + }, + ); + } } diff --git a/lib/services/queries/playlist.dart b/lib/services/queries/playlist.dart index ea53c4b0..0204f9b7 100644 --- a/lib/services/queries/playlist.dart +++ b/lib/services/queries/playlist.dart @@ -203,6 +203,16 @@ class PlaylistQueries { ); } + Query byId(WidgetRef ref, String id) { + return useSpotifyQuery( + "playlist/$id", + (spotify) async { + return await spotify.playlists.get(id); + }, + ref: ref, + ); + } + InfiniteQuery, dynamic, int> featured( WidgetRef ref, ) { diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index eef22b2f..9f8f2fd3 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -21,6 +22,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) catcher_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "CatcherPlugin"); catcher_plugin_register_with_registrar(catcher_registrar); + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 1d7de67a..c7f0e848 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST catcher + file_selector_linux flutter_secure_storage_linux local_notifier media_kit_libs_linux diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 3f63519c..1010a6c4 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import audio_service import audio_session import catcher import device_info_plus +import file_selector_macos import flutter_secure_storage_macos import local_notifier import media_kit_libs_macos_audio @@ -28,6 +29,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) CatcherPlugin.register(with: registry.registrar(forPlugin: "CatcherPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin")) MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin")) diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 6fc950ef..0aed55d5 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -19,5 +19,8 @@ + + com.apple.security.files.user-selected.read-write + \ No newline at end of file diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index 9728552b..19f1c02a 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -1,39 +1,39 @@ - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIconFile - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - NSHumanReadableCopyright - $(PRODUCT_COPYRIGHT) - NSMainNibFile - MainMenu - NSPrincipalClass - NSApplication - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSAllowsArbitraryLoadsForMedia - - - - + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsForMedia + + + + \ No newline at end of file diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 4a447fde..dc5df580 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -17,5 +17,8 @@ + + com.apple.security.files.user-selected.read-write + \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 9d903a25..94eccfdd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -338,6 +338,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: fd832b5384d0d6da4f6df60b854d33accaaeb63aa9e10e736a87381f08dee2cb + url: "https://pub.dev" + source: hosted + version: "0.3.3+5" crypto: dependency: transitive description: @@ -498,14 +506,70 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.4" - file_picker: + file_selector: dependency: "direct main" description: - name: file_picker - sha256: c7a8e25ca60e7f331b153b0cb3d405828f18d3e72a6fa1d9440c86556fffc877 + name: file_selector + sha256: "84eaf3e034d647859167d1f01cfe7b6352488f34c1b4932635012b202014c25b" url: "https://pub.dev" source: hosted - version: "5.3.0" + version: "1.0.1" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: d41e165d6f798ca941d536e5dc93494d50e78c571c28ad60cfe0b0fefeb9f1e7 + url: "https://pub.dev" + source: hosted + version: "0.5.0+3" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: b3fbdda64aa2e335df6e111f6b0f1bb968402ed81d2dd1fa4274267999aa32c2 + url: "https://pub.dev" + source: hosted + version: "0.5.1+6" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "045d372bf19b02aeb69cacf8b4009555fb5f6f0b7ad8016e5f46dd1387ddd492" + url: "https://pub.dev" + source: hosted + version: "0.9.2+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "182c3f8350cee659f7b115e956047ee3dc672a96665883a545e81581b9a82c72" + url: "https://pub.dev" + source: hosted + version: "0.9.3+2" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "0aa47a725c346825a2bd396343ce63ac00bda6eff2fbc43eabe99737dede8262" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: dc6622c4d66cb1bee623ddcc029036603c6cc45c85e4a775bb06008d61c809c1 + url: "https://pub.dev" + source: hosted + version: "0.9.2+1" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: d3547240c20cabf205c7c7f01a50ecdbc413755814d6677f3cb366f04abcead0 + url: "https://pub.dev" + source: hosted + version: "0.9.3+1" fixnum: dependency: transitive description: @@ -788,6 +852,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.1.3" + form_validator: + dependency: "direct main" + description: + name: form_validator + sha256: "8cbe91b7d5260870d6fb9e23acd55d5d1d1fdf2397f0279a4931ac3c0c7bf8fb" + url: "https://pub.dev" + source: hosted + version: "2.1.1" freezed_annotation: dependency: transitive description: @@ -945,6 +1017,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.17" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "7d7f2768df2a8b0a3cefa5ef4f84636121987d403130e70b17ef7e2cf650ba84" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: d32a997bcc4ee135aebca8e272b7c517927aa65a74b9c60a81a2764ef1a0462d + url: "https://pub.dev" + source: hosted + version: "0.8.7+5" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "50bc9ae6a77eea3a8b11af5eb6c661eeb858fdd2f734c2a4fd17086922347ef7" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: c5538cacefacac733c724be7484377923b476216ad1ead35a0d2eadcdc0fc497 + url: "https://pub.dev" + source: hosted + version: "0.8.8+2" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "3f5ad1e8112a9a6111c46d0b57a7be2286a9a07fc6e1976fdf5be2bd31d4ff62" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: ed9b00e63977c93b0d2d2b343685bed9c324534ba5abafbb3dfbd6a780b1b514 + url: "https://pub.dev" + source: hosted + version: "2.9.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" integration_test: dependency: "direct dev" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index a7d43bf1..c1b427cd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,7 +33,6 @@ dependencies: disable_battery_optimization: ^1.1.0+1 duration: ^3.0.12 envied: ^0.3.0 - file_picker: ^5.2.2 fl_query: ^1.0.0-alpha.4 fl_query_hooks: ^1.0.0-alpha.4+1 fl_query_devtools: ^0.1.0-alpha.2 @@ -55,6 +54,7 @@ dependencies: flutter_riverpod: ^2.1.1 flutter_secure_storage: ^8.0.0 flutter_svg: ^1.1.6 + form_validator: ^2.1.1 fuzzywuzzy: ^0.2.0 google_fonts: ^5.1.0 go_router: ^10.0.0 @@ -100,6 +100,8 @@ dependencies: path: plugins/window_size youtube_explode_dart: ^2.0.1 stroke_text: ^0.0.2 + image_picker: ^1.0.4 + file_selector: ^1.0.1 dev_dependencies: build_runner: ^2.3.2 diff --git a/untranslated_messages.json b/untranslated_messages.json index 9e26dfee..74a87896 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1 +1,56 @@ -{} \ No newline at end of file +{ + "bn": [ + "update_playlist", + "update" + ], + + "ca": [ + "update_playlist", + "update" + ], + + "de": [ + "update_playlist", + "update" + ], + + "es": [ + "update_playlist", + "update" + ], + + "fr": [ + "update_playlist", + "update" + ], + + "hi": [ + "update_playlist", + "update" + ], + + "ja": [ + "update_playlist", + "update" + ], + + "pl": [ + "update_playlist", + "update" + ], + + "pt": [ + "update_playlist", + "update" + ], + + "ru": [ + "update_playlist", + "update" + ], + + "zh": [ + "update_playlist", + "update" + ] +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index b8983c9c..089930d3 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -21,6 +22,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { CatcherPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("CatcherPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); LocalNotifierPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index e066d223..b2b08c8e 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST catcher + file_selector_windows flutter_secure_storage_windows local_notifier media_kit_libs_windows_audio