refactor: use shadcn widgets for create playlist and add tracks to playlist dialog

This commit is contained in:
Kingkor Roy Tirtho 2024-12-29 15:07:48 +06:00
parent 047eccfa82
commit 684e595d16
7 changed files with 542 additions and 288 deletions

View File

@ -1,7 +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:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/modules/playlist/playlist_create_dialog.dart';
@ -22,7 +21,7 @@ class PlaylistAddTrackDialog extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final ThemeData(:textTheme) = Theme.of(context);
final typography = Theme.of(context).typography;
final userPlaylists = ref.watch(favoritePlaylistsProvider);
final favoritePlaylistsNotifier =
ref.watch(favoritePlaylistsProvider.notifier);
@ -64,37 +63,38 @@ class PlaylistAddTrackDialog extends HookConsumerWidget {
tracks.map((e) => e.id!).toList(),
),
),
).then((_) => Navigator.pop(context, true));
).then((_) => context.mounted ? Navigator.pop(context, true) : null);
}
return AlertDialog(
insetPadding: EdgeInsets.zero,
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: AlertDialog(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.add_to_playlist,
style: textTheme.titleMedium,
style: typography.large,
),
const Gap(20),
const Spacer(),
const PlaylistCreateDialogButton(),
],
),
actions: [
OutlinedButton(
OutlineButton(
child: Text(context.l10n.cancel),
onPressed: () {
Navigator.pop(context, false);
},
),
FilledButton(
PrimaryButton(
onPressed: onAdd,
child: Text(context.l10n.add),
),
],
content: SizedBox(
height: 300,
width: 300,
child: userPlaylists.isLoading
? const Center(child: CircularProgressIndicator())
: ListView.builder(
@ -102,30 +102,48 @@ class PlaylistAddTrackDialog extends HookConsumerWidget {
itemCount: filteredPlaylists.length,
itemBuilder: (context, index) {
final playlist = filteredPlaylists.elementAt(index);
return CheckboxListTile(
secondary: CircleAvatar(
backgroundImage: UniversalImage.imageProvider(
return Button.ghost(
style: ButtonVariance.ghost.copyWith(
padding: (context, _, __) {
return const EdgeInsets.symmetric(vertical: 8);
},
),
leading: Avatar(
initials:
Avatar.getInitials(playlist.name ?? "Playlist"),
provider: UniversalImage.imageProvider(
playlist.images.asUrlString(
placeholder: ImagePlaceholder.collection,
),
),
),
contentPadding: EdgeInsets.zero,
title: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(playlist.name!),
),
value: playlistsCheck.value[playlist.id] ?? false,
trailing: Checkbox(
state: (playlistsCheck.value[playlist.id] ?? false)
? CheckboxState.checked
: CheckboxState.unchecked,
onChanged: (val) {
playlistsCheck.value = {
...playlistsCheck.value,
playlist.id!: val == true
playlist.id!: val == CheckboxState.checked,
};
},
),
onPressed: () {
playlistsCheck.value = {
...playlistsCheck.value,
playlist.id!:
!(playlistsCheck.value[playlist.id] ?? false),
};
},
child: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(playlist.name!),
),
);
},
),
),
),
);
}
}

View File

@ -0,0 +1,45 @@
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class CheckboxFormBuilderField extends StatelessWidget {
final String name;
final FormFieldValidator<bool>? validator;
final ValueChanged<CheckboxState>? onChanged;
final Widget? leading;
final Widget? trailing;
final bool tristate;
const CheckboxFormBuilderField({
super.key,
required this.name,
this.validator,
this.onChanged,
this.leading,
this.trailing,
this.tristate = false,
});
@override
Widget build(BuildContext context) {
return FormBuilderField<bool>(
name: name,
validator: validator,
builder: (field) {
return Checkbox(
state: tristate && field.value == null
? CheckboxState.indeterminate
: field.value == true
? CheckboxState.checked
: CheckboxState.unchecked,
onChanged: (state) {
field.didChange(state == CheckboxState.checked);
onChanged?.call(state);
},
leading: leading,
trailing: trailing,
tristate: tristate,
);
},
);
}
}

View File

@ -0,0 +1,187 @@
import 'package:flutter/services.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
class TextFormBuilderField extends StatelessWidget {
final String name;
final FormFieldValidator<String>? validator;
final Widget? label;
final TextEditingController? controller;
final bool filled;
final Widget? placeholder;
final AlignmentGeometry? placeholderAlignment;
final AlignmentGeometry? leadingAlignment;
final AlignmentGeometry? trailingAlignment;
final bool border;
final Widget? leading;
final Widget? trailing;
final EdgeInsetsGeometry? padding;
final ValueChanged<String>? onSubmitted;
final VoidCallback? onEditingComplete;
final FocusNode? focusNode;
final VoidCallback? onTap;
final bool enabled;
final bool readOnly;
final bool obscureText;
final String obscuringCharacter;
final String? initialValue;
final int? maxLength;
final MaxLengthEnforcement? maxLengthEnforcement;
final int? maxLines;
final int? minLines;
final BorderRadiusGeometry? borderRadius;
final TextAlign textAlign;
final bool expands;
final TextAlignVertical? textAlignVertical;
final UndoHistoryController? undoController;
final ValueChanged<String>? onChanged;
final Iterable<String>? autofillHints;
final void Function(PointerDownEvent event)? onTapOutside;
final List<TextInputFormatter>? inputFormatters;
final TextStyle? style;
final EditableTextContextMenuBuilder? contextMenuBuilder;
final bool useNativeContextMenu;
final bool? isCollapsed;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final Clip clipBehavior;
final bool autofocus;
final WidgetStatesController? statesController;
const TextFormBuilderField({
super.key,
required this.name,
this.label,
this.validator,
this.controller,
this.maxLength,
this.maxLengthEnforcement,
this.maxLines = 1,
this.minLines,
this.filled = false,
this.placeholder,
this.border = true,
this.leading,
this.trailing,
this.padding,
this.onSubmitted,
this.onEditingComplete,
this.focusNode,
this.onTap,
this.enabled = true,
this.readOnly = false,
this.obscureText = false,
this.obscuringCharacter = '',
this.initialValue,
this.borderRadius,
this.keyboardType,
this.textAlign = TextAlign.start,
this.expands = false,
this.textAlignVertical = TextAlignVertical.center,
this.autofillHints,
this.undoController,
this.onChanged,
this.onTapOutside,
this.inputFormatters,
this.style,
this.contextMenuBuilder = TextField.defaultContextMenuBuilder,
this.useNativeContextMenu = false,
this.isCollapsed,
this.textInputAction,
this.clipBehavior = Clip.hardEdge,
this.autofocus = false,
this.placeholderAlignment,
this.leadingAlignment,
this.trailingAlignment,
this.statesController,
});
@override
Widget build(BuildContext context) {
return FormBuilderField<String>(
name: name,
validator: validator,
onChanged: (value) {
if (value == null) return;
onChanged?.call(value);
},
builder: (field) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 5,
children: [
if (label != null)
DefaultTextStyle(
style: context.theme.typography.semiBold.copyWith(
color: field.hasError
? context.theme.colorScheme.destructive
: context.theme.colorScheme.foreground,
),
child: label!,
),
TextField(
controller: controller,
maxLength: maxLength,
maxLengthEnforcement: maxLengthEnforcement,
maxLines: maxLines,
minLines: minLines,
filled: filled,
placeholder: placeholder,
border: border,
leading: leading,
trailing: trailing,
padding: padding,
onSubmitted: (value) {
field.validate();
field.save();
onSubmitted?.call(value);
},
onEditingComplete: () {
field.save();
onEditingComplete?.call();
},
focusNode: focusNode,
onTap: onTap,
enabled: enabled,
readOnly: readOnly,
obscureText: obscureText,
obscuringCharacter: obscuringCharacter,
initialValue: field.value,
borderRadius: borderRadius,
textAlign: textAlign,
expands: expands,
textAlignVertical: textAlignVertical,
autofillHints: autofillHints,
undoController: undoController,
onChanged: (value) {
field.didChange(value);
},
onTapOutside: onTapOutside,
inputFormatters: inputFormatters,
style: style,
contextMenuBuilder: contextMenuBuilder,
useNativeContextMenu: useNativeContextMenu,
isCollapsed: isCollapsed,
keyboardType: keyboardType,
textInputAction: textInputAction,
clipBehavior: clipBehavior,
autofocus: autofocus,
placeholderAlignment: placeholderAlignment,
leadingAlignment: leadingAlignment,
trailingAlignment: trailingAlignment,
statesController: statesController,
),
if (field.hasError)
Text(
field.errorText ?? "",
style: TextStyle(
color: context.theme.colorScheme.destructive,
),
),
],
),
);
}
}

View File

@ -90,7 +90,7 @@ class TrackPresentation extends HookConsumerWidget {
},
),
const PresentationListSection(),
const SliverGap(200),
const SliverSafeArea(sliver: SliverGap(10)),
],
),
),

View File

@ -2,20 +2,23 @@ import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:form_validator/form_validator.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/form/checkbox_form_field.dart';
import 'package:spotube/components/form/text_form_field.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/extensions/string.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/provider/spotify_provider.dart';
@ -23,25 +26,23 @@ class PlaylistCreateDialog extends HookConsumerWidget {
/// Track ids to add to the playlist
final List<String> trackIds;
final String? playlistId;
PlaylistCreateDialog({
const PlaylistCreateDialog({
super.key,
this.trackIds = const [],
this.playlistId,
});
final formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context, ref) {
return ScaffoldMessenger(
child: Scaffold(
backgroundColor: Colors.transparent,
body: HookBuilder(builder: (context) {
final userPlaylists = ref.watch(favoritePlaylistsProvider);
final playlist = ref.watch(playlistProvider(playlistId ?? ""));
final playlistNotifier =
ref.watch(playlistProvider(playlistId ?? "").notifier);
final isSubmitting = useState(false);
final formKey = useMemoized(() => GlobalKey<FormBuilderState>(), []);
final updatingPlaylist = useMemoized(
() => userPlaylists.asData?.value.items
.firstWhereOrNull((playlist) => playlist.id == playlistId),
@ -51,52 +52,46 @@ class PlaylistCreateDialog extends HookConsumerWidget {
],
);
final playlistName = useTextEditingController(
text: updatingPlaylist?.name,
);
final description = useTextEditingController(
text: updatingPlaylist?.description?.unescapeHtml(),
);
final public = useState(
updatingPlaylist?.public ?? false,
);
final collaborative = useState(
updatingPlaylist?.collaborative ?? false,
);
final image = useState<XFile?>(null);
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 ?? context.l10n.epic_failure),
style: theme.textTheme.bodyMedium!.copyWith(
color: theme.colorScheme.onError,
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Basic(
title: Text(
l10n.error(error.message ?? l10n.epic_failure),
style: theme.typography.normal.copyWith(
color: theme.colorScheme.destructive,
),
),
backgroundColor: theme.colorScheme.error,
),
);
},
);
}
}, [scaffold, l10n, theme]);
}, [l10n, theme]);
Future<void> onCreate() async {
if (!formKey.currentState!.validate()) return;
if (!formKey.currentState!.saveAndValidate()) return;
try {
isSubmitting.value = true;
final values = formKey.currentState!.value;
final PlaylistInput payload = (
playlistName: playlistName.text,
collaborative: collaborative.value,
public: public.value,
description: description.text,
base64Image: image.value?.path != null
? await image.value!
playlistName: values['playlistName'],
collaborative: values['collaborative'],
public: values['public'],
description: values['description'],
base64Image: (values['image'] as XFile?)?.path != null
? await (values['image'] as XFile)
.readAsBytes()
.then((bytes) => base64Encode(bytes))
: null,
@ -107,12 +102,14 @@ class PlaylistCreateDialog extends HookConsumerWidget {
} else {
await playlistNotifier.create(payload, onError);
}
} finally {
isSubmitting.value = false;
if (context.mounted &&
!ref.read(playlistProvider(playlistId ?? "")).hasError) {
context.pop();
}
}
}
return AlertDialog(
title: Text(
@ -121,35 +118,36 @@ class PlaylistCreateDialog extends HookConsumerWidget {
: context.l10n.create_a_playlist,
),
actions: [
OutlinedButton(
Button.outline(
child: Text(context.l10n.cancel),
onPressed: () {
Navigator.pop(context);
},
),
FilledButton(
onPressed: playlist.isLoading ? null : onCreate,
Button.primary(
onPressed: onCreate,
enabled: !playlist.isLoading & !isSubmitting.value,
child: Text(
isUpdatingPlaylist
? context.l10n.update
: context.l10n.create,
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(
child: FormBuilder(
key: formKey,
initialValue: {
'playlistName': updatingPlaylist?.name,
'description': updatingPlaylist?.description,
'public': updatingPlaylist?.public ?? false,
'collaborative': updatingPlaylist?.collaborative ?? false,
},
child: ListView(
shrinkWrap: true,
children: [
FormField<XFile?>(
initialValue: image.value,
onSaved: (newValue) {
image.value = newValue;
},
FormBuilderField<XFile?>(
name: 'image',
validator: (value) {
if (value == null) return null;
final file = File(value.path);
@ -157,10 +155,15 @@ class PlaylistCreateDialog extends HookConsumerWidget {
if (file.lengthSync() > 256000) {
return "Image size should be less than 256kb";
}
if (extension(file.path) != ".png") {
return "Image should be in PNG format";
}
return null;
},
builder: (field) {
return Column(
spacing: 10,
children: [
UniversalImage(
path: field.value?.path ??
@ -169,22 +172,21 @@ class PlaylistCreateDialog extends HookConsumerWidget {
),
height: 200,
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FilledButton.icon(
icon: const Icon(SpotubeIcons.edit),
label: Text(
Button.secondary(
leading: const Icon(SpotubeIcons.edit),
child: Text(
field.value?.path != null ||
updatingPlaylist?.images != null
? context.l10n.change_cover
: context.l10n.add_cover,
),
onPressed: () async {
final imageFile = await ImagePicker()
.pickImage(
source: ImageSource.gallery);
final imageFile = await ImagePicker().pickImage(
source: ImageSource.gallery,
);
if (imageFile != null) {
field.didChange(imageFile);
@ -194,16 +196,10 @@ class PlaylistCreateDialog extends HookConsumerWidget {
},
),
const SizedBox(width: 10),
IconButton.filled(
IconButton.destructive(
icon: const Icon(SpotubeIcons.trash),
style: IconButton.styleFrom(
backgroundColor:
theme.colorScheme.errorContainer,
foregroundColor: theme.colorScheme.error,
),
onPressed: field.value == null
? null
: () {
enabled: field.value != null,
onPressed: () {
field.didChange(null);
field.validate();
field.save();
@ -214,52 +210,45 @@ class PlaylistCreateDialog extends HookConsumerWidget {
if (field.hasError)
Text(
field.errorText ?? "",
style: theme.textTheme.bodyMedium!.copyWith(
color: theme.colorScheme.error,
style: theme.typography.normal.copyWith(
color: theme.colorScheme.destructive,
),
)
],
);
}),
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,
const Gap(20),
TextFormBuilderField(
name: 'playlistName',
label: Text(context.l10n.playlist_name),
placeholder: Text(context.l10n.name_of_playlist),
validator: FormBuilderValidators.required(),
),
const Gap(20),
TextFormBuilderField(
name: 'description',
label: Text(context.l10n.description),
validator: FormBuilderValidators.required(),
placeholder: Text(context.l10n.description),
keyboardType: TextInputType.multiline,
validator: ValidationBuilder().required().build(),
maxLines: 5,
),
const SizedBox(height: 10),
CheckboxListTile(
title: Text(context.l10n.public),
value: public.value,
onChanged: (val) => public.value = val ?? false,
const Gap(20),
CheckboxFormBuilderField(
name: 'public',
trailing: Text(context.l10n.public),
),
const SizedBox(height: 10),
CheckboxListTile(
title: Text(context.l10n.collaborative),
value: collaborative.value,
onChanged: (val) => collaborative.value = val ?? false,
const Gap(10),
CheckboxFormBuilderField(
name: 'collaborative',
trailing: Text(context.l10n.collaborative),
),
],
),
),
),
);
}),
),
);
}
}
@ -269,7 +258,10 @@ class PlaylistCreateDialogButton extends HookConsumerWidget {
showPlaylistDialog(BuildContext context, SpotifyApi spotify) {
showDialog(
context: context,
builder: (context) => PlaylistCreateDialog(),
alignment: Alignment.center,
builder: (context) => const ToastLayer(
child: PlaylistCreateDialog(),
),
);
}
@ -279,21 +271,15 @@ class PlaylistCreateDialogButton extends HookConsumerWidget {
final spotify = ref.watch(spotifyProvider);
if (mediaQuery.smAndDown) {
return ElevatedButton(
style: FilledButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.primary,
),
child: const Icon(SpotubeIcons.addFilled),
return IconButton.secondary(
icon: const Icon(SpotubeIcons.addFilled),
onPressed: () => showPlaylistDialog(context, spotify),
);
}
return FilledButton.tonalIcon(
style: FilledButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.primary,
),
icon: const Icon(SpotubeIcons.addFilled),
label: Text(context.l10n.create_playlist),
return Button.secondary(
leading: const Icon(SpotubeIcons.addFilled),
child: Text(context.l10n.create_playlist),
onPressed: () => showPlaylistDialog(context, spotify),
);
}

View File

@ -757,6 +757,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0+1"
flutter_form_builder:
dependency: "direct main"
description:
name: flutter_form_builder
sha256: "39aee5a2548df0b3979a83eea38468116a888341fbca8a92c4be18a486a7bb57"
url: "https://pub.dev"
source: hosted
version: "9.6.0"
flutter_gen_core:
dependency: transitive
description:
@ -972,6 +980,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
form_builder_validators:
dependency: "direct main"
description:
name: form_builder_validators
sha256: "517fb884183fff7a0ef3db7d375981011da26ee452f20fb3d2e788ad527ad01d"
url: "https://pub.dev"
source: hosted
version: "11.1.1"
form_validator:
dependency: "direct main"
description:
@ -2670,5 +2686,5 @@ packages:
source: hosted
version: "2.3.6"
sdks:
dart: ">=3.5.3 <4.0.0"
dart: ">=3.6.0 <4.0.0"
flutter: ">=3.27.0"

View File

@ -54,6 +54,7 @@ dependencies:
flutter_discord_rpc: ^1.0.0
flutter_displaymode: ^0.6.0
flutter_feather_icons: ^2.0.0+1
flutter_form_builder: ^9.6.0
flutter_hooks: ^0.20.5
flutter_inappwebview: ^6.1.3
flutter_localizations:
@ -62,6 +63,7 @@ dependencies:
flutter_riverpod: ^2.5.1
flutter_secure_storage: ^9.0.0
flutter_sharing_intent: ^1.1.0
form_builder_validators: ^11.1.1
form_validator: ^2.1.1
freezed_annotation: ^2.4.1
fuzzywuzzy: ^1.1.6