refactor: generate playlist to shadcn

This commit is contained in:
Kingkor Roy Tirtho 2025-01-05 16:16:57 +06:00
parent b8f2495acb
commit dd0bb01af5
11 changed files with 868 additions and 761 deletions

View File

@ -31,8 +31,7 @@ class ButtonTile extends StatelessWidget {
onPressed: onPressed,
style: style.copyWith(
decoration: (context, states, value) {
final decoration = ButtonVariance.outline.decoration(context, states)
as BoxDecoration;
final decoration = style.decoration(context, states) as BoxDecoration;
if (selected && style == ButtonVariance.outline) {
return decoration.copyWith(
@ -47,7 +46,7 @@ class ButtonTile extends StatelessWidget {
return decoration;
},
iconTheme: (context, states, value) {
final iconTheme = ButtonVariance.outline.iconTheme(context, states);
final iconTheme = style.iconTheme(context, states);
if (selected && style == ButtonVariance.outline) {
return iconTheme.copyWith(
@ -58,7 +57,7 @@ class ButtonTile extends StatelessWidget {
return iconTheme;
},
textStyle: (context, states, value) {
final textStyle = ButtonVariance.outline.textStyle(context, states);
final textStyle = style.textStyle(context, states);
if (selected && style == ButtonVariance.outline) {
return textStyle.copyWith(

View File

@ -22,7 +22,7 @@
"filter_playlists": "Filter your playlists...",
"liked_tracks": "Liked Tracks",
"liked_tracks_description": "All your liked tracks",
"create_playlist": "Create Playlist",
"playlist": "Playlist",
"create_a_playlist": "Create a playlist",
"update_playlist": "Update playlist",
"create": "Create",
@ -194,7 +194,7 @@
"invidious_instance": "Invidious Server Instance",
"invidious_description": "The Invidious server instance to use for track matching",
"invidious_warning": "Some of them might not work well. So use at your own risk",
"generate_playlist": "Generate Playlist",
"generate": "Generate",
"track_exists": "Track {track} already exists",
"replace_downloaded_tracks": "Replace all downloaded tracks",
"skip_download_tracks": "Skip downloading all downloaded tracks",

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart';
@ -29,23 +29,21 @@ class RecommendationAttributeDials extends HookWidget {
@override
Widget build(BuildContext context) {
final animation = useAnimationController(
duration: const Duration(milliseconds: 300),
);
final labelStyle = Theme.of(context).textTheme.labelSmall?.copyWith(
final labelStyle = Theme.of(context).typography.small.copyWith(
fontWeight: FontWeight.w500,
);
final minSlider = Row(
spacing: 5,
children: [
Text(context.l10n.min, style: labelStyle),
Expanded(
child: Slider(
value: values.min / base,
value: SliderValue.single(values.min / base),
min: 0,
max: 1,
onChanged: (value) => onChanged((
min: value * base,
min: value.value * base,
target: values.target,
max: values.max,
)),
@ -55,16 +53,17 @@ class RecommendationAttributeDials extends HookWidget {
);
final targetSlider = Row(
spacing: 5,
children: [
Text(context.l10n.target, style: labelStyle),
Expanded(
child: Slider(
value: values.target / base,
value: SliderValue.single(values.target / base),
min: 0,
max: 1,
onChanged: (value) => onChanged((
min: values.min,
target: value * base,
target: value.value * base,
max: values.max,
)),
),
@ -73,52 +72,25 @@ class RecommendationAttributeDials extends HookWidget {
);
final maxSlider = Row(
spacing: 5,
children: [
Text(context.l10n.max, style: labelStyle),
Expanded(
child: Slider(
value: values.max / base,
value: SliderValue.single(values.max / base),
min: 0,
max: 1,
onChanged: (value) => onChanged((
min: values.min,
target: values.target,
max: value * base,
max: value.value * base,
)),
),
),
],
);
return LayoutBuilder(builder: (context, constrain) {
return Card(
child: ExpansionTile(
title: DefaultTextStyle(
style: Theme.of(context).textTheme.titleSmall!,
child: title,
),
shape: const Border(),
leading: AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Transform.rotate(
angle: (animation.value * 3.14) / 2,
child: child,
);
},
child: const Icon(Icons.chevron_right),
),
trailing: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: ToggleButtons(
borderRadius: BorderRadius.circular(8),
textStyle: labelStyle,
isSelected: [
values == lowValues(base),
values == moderateValues(base),
values == highValues(base),
],
onPressed: (index) {
void onSelected(int index) {
RecommendationAttribute newValues = zeroValues;
switch (index) {
case 0:
@ -137,21 +109,48 @@ class RecommendationAttributeDials extends HookWidget {
} else {
onChanged(newValues);
}
},
}
return LayoutBuilder(builder: (context, constrain) {
return Accordion(
items: [
AccordionItem(
trigger: AccordionTrigger(
child: SizedBox(
width: double.infinity,
child: Basic(
title: title.semiBold(),
trailing: Row(
spacing: 5,
children: [
Text(context.l10n.low),
Text(" ${context.l10n.moderate} "),
Text(context.l10n.high),
Toggle(
value: values == lowValues(base),
onChanged: (value) => onSelected(0),
style:
const ButtonStyle.outline(size: ButtonSize.small),
child: Text(context.l10n.low),
),
Toggle(
value: values == moderateValues(base),
onChanged: (value) => onSelected(1),
style:
const ButtonStyle.outline(size: ButtonSize.small),
child: Text(context.l10n.moderate),
),
Toggle(
value: values == highValues(base),
onChanged: (value) => onSelected(2),
style:
const ButtonStyle.outline(size: ButtonSize.small),
child: Text(context.l10n.high),
),
],
),
),
onExpansionChanged: (value) {
if (value) {
animation.forward();
} else {
animation.reverse();
}
},
),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (constrain.mdAndUp)
Row(
@ -176,6 +175,8 @@ class RecommendationAttributeDials extends HookWidget {
),
],
),
),
],
);
});
}

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/modules/library/playlist_generate/recommendation_attribute_dials.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
@ -21,13 +21,6 @@ class RecommendationAttributeFields extends HookWidget {
@override
Widget build(BuildContext context) {
final animation = useAnimationController(
duration: const Duration(milliseconds: 300),
);
final labelStyle = Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w500,
);
final minController = useTextEditingController(text: values.min.toString());
final targetController =
useTextEditingController(text: values.target.toString());
@ -53,73 +46,47 @@ class RecommendationAttributeFields extends HookWidget {
};
}, [values]);
final minField = TextField(
final minField = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 5,
children: [
Text(context.l10n.min).semiBold(),
NumberInput(
controller: minController,
decoration: InputDecoration(
labelText: context.l10n.min,
isDense: true,
),
keyboardType: const TextInputType.numberWithOptions(
decimal: false,
signed: true,
allowDecimals: false,
),
],
);
final targetField = TextField(
final targetField = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 5,
children: [
Text(context.l10n.target).semiBold(),
NumberInput(
controller: targetController,
decoration: InputDecoration(
labelText: context.l10n.target,
isDense: true,
),
keyboardType: const TextInputType.numberWithOptions(
decimal: false,
signed: true,
allowDecimals: false,
),
],
);
final maxField = TextField(
final maxField = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 5,
children: [
Text(context.l10n.max).semiBold(),
NumberInput(
controller: maxController,
decoration: InputDecoration(
labelText: context.l10n.max,
isDense: true,
),
keyboardType: const TextInputType.numberWithOptions(
decimal: false,
signed: true,
allowDecimals: false,
),
],
);
return LayoutBuilder(builder: (context, constrain) {
return Card(
child: ExpansionTile(
title: DefaultTextStyle(
style: Theme.of(context).textTheme.titleSmall!,
child: title,
),
shape: const Border(),
leading: AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Transform.rotate(
angle: (animation.value * 3.14) / 2,
child: child,
);
},
child: const Icon(Icons.chevron_right),
),
trailing: presets == null
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: ToggleButtons(
borderRadius: BorderRadius.circular(8),
textStyle: labelStyle,
isSelected: presets!.values
.map((value) => value == values)
.toList(),
onPressed: (index) {
RecommendationAttribute newValues =
presets!.values.elementAt(index);
void onSelected(int index) {
RecommendationAttribute newValues = presets!.values.elementAt(index);
if (newValues == values) {
onChanged(zeroValues);
minController.text = zeroValues.min.toString();
@ -131,20 +98,51 @@ class RecommendationAttributeFields extends HookWidget {
targetController.text = newValues.target.toString();
maxController.text = newValues.max.toString();
}
},
children: presets!.keys.map((key) => Text(key)).toList(),
),
),
onExpansionChanged: (value) {
if (value) {
animation.forward();
} else {
animation.reverse();
}
return LayoutBuilder(builder: (context, constraints) {
return Accordion(
items: [
AccordionItem(
trigger: AccordionTrigger(
child: SizedBox(
width: double.infinity,
child: Basic(
title: title.semiBold(),
trailing: presets == null
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
spacing: 5,
children: [
for (final presetEntry in presets?.entries
.toList() ??
<MapEntry<String, RecommendationAttribute>>[])
Toggle(
value: presetEntry.value == values,
style: const ButtonStyle.outline(
size: ButtonSize.small,
),
onChanged: (value) {
onSelected(
presets!.entries.toList().indexWhere(
(s) => s.key == presetEntry.key),
);
},
child: Text(presetEntry.key),
),
],
),
),
),
),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
if (constrain.mdAndUp)
if (constraints.mdAndUp)
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
@ -173,6 +171,8 @@ class RecommendationAttributeFields extends HookWidget {
const SizedBox(height: 8),
],
),
),
],
);
});
}

View File

@ -1,8 +1,9 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' show Autocomplete;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/extensions/constrains.dart';
enum SelectedItemDisplayType {
@ -20,10 +21,13 @@ class SeedsMultiAutocomplete<T extends Object> extends HookWidget {
final Widget Function(T option) selectedSeedBuilder;
final String Function(T option) displayStringForOption;
final InputDecoration? inputDecoration;
final bool enabled;
final SelectedItemDisplayType selectedItemDisplayType;
final Widget? placeholder;
final Widget? leading;
final Widget? trailing;
final Widget? label;
const SeedsMultiAutocomplete({
super.key,
@ -32,9 +36,12 @@ class SeedsMultiAutocomplete<T extends Object> extends HookWidget {
required this.autocompleteOptionBuilder,
required this.displayStringForOption,
required this.selectedSeedBuilder,
this.inputDecoration,
this.enabled = true,
this.selectedItemDisplayType = SelectedItemDisplayType.wrap,
this.placeholder,
this.leading,
this.trailing,
this.label,
});
@override
@ -61,6 +68,10 @@ class SeedsMultiAutocomplete<T extends Object> extends HookWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (label != null) ...[
label!.semiBold(),
const Gap(8),
],
LayoutBuilder(builder: (context, constrains) {
return Container(
key: containerKey.value,
@ -101,13 +112,15 @@ class SeedsMultiAutocomplete<T extends Object> extends HookWidget {
focusNode,
onFieldSubmitted,
) {
return TextFormField(
return TextField(
controller: seedController,
onChanged: (value) => textEditingController.text = value,
focusNode: focusNode,
onFieldSubmitted: (_) => onFieldSubmitted(),
onSubmitted: (_) => onFieldSubmitted(),
enabled: enabled,
decoration: inputDecoration,
leading: leading,
trailing: trailing,
placeholder: placeholder,
);
},
),
@ -120,15 +133,19 @@ class SeedsMultiAutocomplete<T extends Object> extends HookWidget {
runSpacing: 4,
children: seeds.value.map(selectedSeedBuilder).toList(),
),
SelectedItemDisplayType.list => Card(
margin: EdgeInsets.zero,
SelectedItemDisplayType.list => AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: seeds.value.isEmpty
? const SizedBox.shrink()
: Card(
child: Column(
children: [
for (final seed in seeds.value) ...[
selectedSeedBuilder(seed),
if (seeds.value.length > 1 && seed != seeds.value.last)
if (seeds.value.length > 1 &&
seed != seeds.value.last)
Divider(
color: theme.colorScheme.primaryContainer,
color: theme.colorScheme.secondary,
height: 1,
indent: 12,
endIndent: 12,
@ -137,6 +154,7 @@ class SeedsMultiAutocomplete<T extends Object> extends HookWidget {
],
),
),
),
},
],
);

View File

@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/components/ui/button_tile.dart';
import 'package:spotube/extensions/image.dart';
class SimpleTrackTile extends HookWidget {
@ -17,7 +18,7 @@ class SimpleTrackTile extends HookWidget {
@override
Widget build(BuildContext context) {
return ListTile(
return ButtonTile(
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: UniversalImage(
@ -28,18 +29,17 @@ class SimpleTrackTile extends HookWidget {
width: 40,
),
),
horizontalTitleGap: 10,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: Text(track.name!),
trailing: onDelete == null
? null
: IconButton(
: IconButton.ghost(
icon: const Icon(SpotubeIcons.close),
onPressed: onDelete,
),
subtitle: Text(
track.artists?.map((e) => e.name).join(", ") ?? track.album?.name ?? "",
),
style: ButtonVariance.ghost,
);
}
}

View File

@ -110,7 +110,7 @@ class UserPlaylists extends HookConsumerWidget {
const Gap(10),
Button.primary(
leading: const Icon(SpotubeIcons.magic),
child: Text(context.l10n.generate_playlist),
child: Text(context.l10n.generate),
onPressed: () {
ServiceUtils.pushNamed(
context,

View File

@ -16,7 +16,6 @@ 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/provider/spotify/spotify.dart';
@ -267,19 +266,11 @@ class PlaylistCreateDialogButton extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final mediaQuery = MediaQuery.of(context);
final spotify = ref.watch(spotifyProvider);
if (mediaQuery.smAndDown) {
return IconButton.secondary(
icon: const Icon(SpotubeIcons.addFilled),
onPressed: () => showPlaylistDialog(context, spotify),
);
}
return Button.secondary(
leading: const Icon(SpotubeIcons.addFilled),
child: Text(context.l10n.create_playlist),
child: Text(context.l10n.playlist),
onPressed: () => showPlaylistDialog(context, spotify),
);
}

View File

@ -1,12 +1,15 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotify_markets.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/modules/library/playlist_generate/multi_select_field.dart';
import 'package:spotube/components/button/back_button.dart';
import 'package:spotube/components/ui/button_tile.dart';
import 'package:spotube/modules/library/playlist_generate/recommendation_attribute_dials.dart';
import 'package:spotube/modules/library/playlist_generate/recommendation_attribute_fields.dart';
import 'package:spotube/modules/library/playlist_generate/seeds_multi_autocomplete.dart';
@ -33,7 +36,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
final spotify = ref.watch(spotifyProvider);
final theme = Theme.of(context);
final textTheme = theme.textTheme;
final typography = theme.typography;
final preferences = ref.watch(userPreferencesProvider);
final genresCollection = ref.watch(categoryGenresProvider);
@ -59,14 +62,11 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
final artistAutoComplete = SeedsMultiAutocomplete<Artist>(
seeds: artists,
enabled: enabled,
inputDecoration: InputDecoration(
labelText: context.l10n.artists,
labelStyle: textTheme.titleMedium,
helperText: context.l10n.select_up_to_count_type(
label: Text(context.l10n.artists),
placeholder: Text(context.l10n.select_up_to_count_type(
leftSeedCount,
context.l10n.artists,
),
),
)),
fetchSeeds: (textEditingValue) => spotify.search
.get(
textEditingValue.text,
@ -83,15 +83,15 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
)
.toList(),
),
autocompleteOptionBuilder: (option, onSelected) => ListTile(
leading: CircleAvatar(
backgroundImage: UniversalImage.imageProvider(
autocompleteOptionBuilder: (option, onSelected) => ButtonTile(
leading: Avatar(
initials: "O",
provider: UniversalImage.imageProvider(
option.images.asUrlString(
placeholder: ImagePlaceholder.artist,
),
),
),
horizontalTitleGap: 20,
title: Text(option.name!),
subtitle: option.genres?.isNotEmpty != true
? null
@ -101,49 +101,48 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
children: option.genres!.mapIndexed(
(index, genre) {
return Chip(
label: Text(genre),
labelStyle: textTheme.bodySmall?.copyWith(
color: theme.colorScheme.secondary,
fontWeight: FontWeight.w600,
),
side: BorderSide.none,
backgroundColor: theme.colorScheme.secondaryContainer,
style: ButtonVariance.secondary,
child: Text(genre),
);
},
).toList(),
),
onTap: () => onSelected(option),
onPressed: () => onSelected(option),
style: ButtonVariance.ghost,
),
displayStringForOption: (option) => option.name!,
selectedSeedBuilder: (artist) => Chip(
avatar: CircleAvatar(
backgroundImage: UniversalImage.imageProvider(
selectedSeedBuilder: (artist) => OutlineBadge(
leading: Avatar(
initials: artist.name!.substring(0, 1),
size: 30,
provider: UniversalImage.imageProvider(
artist.images.asUrlString(
placeholder: ImagePlaceholder.artist,
),
),
),
label: Text(artist.name!),
onDeleted: () {
trailing: IconButton.ghost(
icon: const Icon(SpotubeIcons.close),
onPressed: () {
artists.value = [
...artists.value..removeWhere((element) => element.id == artist.id)
...artists.value
..removeWhere((element) => element.id == artist.id)
];
},
),
child: Text(artist.name!),
),
);
final tracksAutocomplete = SeedsMultiAutocomplete<Track>(
seeds: tracks,
enabled: enabled,
selectedItemDisplayType: SelectedItemDisplayType.list,
inputDecoration: InputDecoration(
labelText: context.l10n.tracks,
labelStyle: textTheme.titleMedium,
helperText: context.l10n.select_up_to_count_type(
label: Text(context.l10n.tracks),
placeholder: Text(context.l10n.select_up_to_count_type(
leftSeedCount,
context.l10n.tracks,
),
),
)),
fetchSeeds: (textEditingValue) => spotify.search
.get(
textEditingValue.text,
@ -160,22 +159,23 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
)
.toList(),
),
autocompleteOptionBuilder: (option, onSelected) => ListTile(
leading: CircleAvatar(
backgroundImage: UniversalImage.imageProvider(
autocompleteOptionBuilder: (option, onSelected) => ButtonTile(
leading: Avatar(
initials: option.name!.substring(0, 1),
provider: UniversalImage.imageProvider(
(option.album?.images).asUrlString(
placeholder: ImagePlaceholder.artist,
),
),
),
horizontalTitleGap: 20,
title: Text(option.name!),
subtitle: Text(
option.artists?.map((e) => e.name).join(", ") ??
option.album?.name ??
"",
),
onTap: () => onSelected(option),
onPressed: () => onSelected(option),
style: ButtonVariance.ghost,
),
displayStringForOption: (option) => option.name!,
selectedSeedBuilder: (option) => SimpleTrackTile(
@ -188,42 +188,65 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
),
);
final genreSelector = MultiSelectField<String>(
options: genresCollection.asData?.value ?? [],
selectedOptions: genres.value,
getValueForOption: (option) => option,
onSelected: (value) {
final genreSelector = MultiSelect<String>(
value: genres.value,
searchFilter: (item, query) {
return item.toLowerCase().contains(query.toLowerCase()) ? 1 : 0;
},
onChanged: (value) {
if (!enabled) return;
genres.value = value;
},
dialogTitle: Text(context.l10n.select_genres),
label: Text(context.l10n.add_genres),
helperText: context.l10n.select_up_to_count_type(
itemBuilder: (context, item) => Text(item),
searchPlaceholder: Text(context.l10n.select_genres),
orderSelectedFirst: false,
popoverAlignment: Alignment.bottomCenter,
popupConstraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * .8,
),
placeholder: Text(
context.l10n.select_up_to_count_type(
leftSeedCount,
context.l10n.genre,
),
enabled: enabled,
),
children: [
for (final option in genresCollection.asData?.value ?? <String>[])
SelectItemButton(
value: option,
child: Text(option),
),
],
);
final countrySelector = ValueListenableBuilder(
valueListenable: market,
builder: (context, value, _) {
return DropdownButtonFormField<Market>(
decoration: InputDecoration(
labelText: context.l10n.country,
labelStyle: textTheme.titleMedium,
return Select<Market>(
placeholder: Text(context.l10n.country),
value: market.value,
onChanged: (value) {
market.value = value!;
},
searchFilter: (item, query) {
return item.name.toLowerCase().contains(query.toLowerCase())
? 1
: 0;
},
searchPlaceholder: Text(context.l10n.search),
popupConstraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * .8,
),
isExpanded: true,
items: spotifyMarkets
popoverAlignment: Alignment.bottomCenter,
itemBuilder: (context, value) => Text(value.name),
children: spotifyMarkets
.map(
(country) => DropdownMenuItem(
(country) => SelectItemButton(
value: country.$1,
child: Text(country.$2),
),
)
.toList(),
value: market.value,
onChanged: (value) {
market.value = value!;
},
);
},
);
@ -231,19 +254,17 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
final controller = useScrollController();
return Scaffold(
appBar: TitleBar(
headers: [
TitleBar(
leading: const [BackButton()],
title: Text(context.l10n.generate_playlist),
),
body: Scrollbar(
title: Text(context.l10n.generate),
)
],
child: Scrollbar(
controller: controller,
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: Breakpoints.lg),
child: SliderTheme(
data: const SliderThemeData(
overlayShape: RoundSliderOverlayShape(),
),
child: SafeArea(
child: LayoutBuilder(builder: (context, constrains) {
return ScrollConfiguration(
@ -261,35 +282,36 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
children: [
Text(
context.l10n.number_of_tracks_generate,
style: textTheme.titleMedium,
style: typography.semiBold,
),
Row(
spacing: 5,
children: [
Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
color: theme.colorScheme.primary
.withAlpha(25),
shape: BoxShape.circle,
),
child: Text(
value.round().toString(),
style: textTheme.bodyLarge?.copyWith(
color: theme
.colorScheme.primaryContainer,
style: typography.large.copyWith(
color: theme.colorScheme.primary,
),
),
),
Expanded(
child: Slider(
value: value.toDouble(),
value:
SliderValue.single(value.toDouble()),
min: 10,
max: 100,
divisions: 9,
label: value.round().toString(),
onChanged: (value) {
limit.value = value.round();
limit.value = value.value.round();
},
),
)
@ -616,10 +638,10 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
);
},
),
const SizedBox(height: 20),
FilledButton.icon(
icon: const Icon(SpotubeIcons.magic),
label: Text(context.l10n.generate_playlist),
const Gap(20),
Center(
child: Button.primary(
leading: const Icon(SpotubeIcons.magic),
onPressed: artists.value.isEmpty &&
tracks.value.isEmpty &&
genres.value.isEmpty
@ -643,6 +665,8 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
extra: routeState,
);
},
child: Text(context.l10n.generate),
),
),
],
),
@ -652,7 +676,6 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
),
),
),
),
);
}
}

View File

@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/button/back_button.dart';
import 'package:spotube/modules/library/playlist_generate/simple_track_tile.dart';
import 'package:spotube/modules/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart';
@ -27,7 +28,7 @@ class PlaylistGenerateResultPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final router = GoRouter.of(context);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final generatedPlaylist = ref.watch(generatePlaylistProvider(state));
@ -48,8 +49,10 @@ class PlaylistGenerateResultPage extends HookConsumerWidget {
(generatedPlaylist.asData?.value.length ?? 0);
return Scaffold(
appBar: const TitleBar(leading: [BackButton()]),
body: generatedPlaylist.isLoading
headers: const [
TitleBar(leading: [BackButton()])
],
child: generatedPlaylist.isLoading
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@ -74,9 +77,8 @@ class PlaylistGenerateResultPage extends HookConsumerWidget {
),
shrinkWrap: true,
children: [
FilledButton.tonalIcon(
icon: const Icon(SpotubeIcons.play),
label: Text(context.l10n.play),
Button.primary(
leading: const Icon(SpotubeIcons.play),
onPressed: selectedTracks.value.isEmpty
? null
: () async {
@ -90,10 +92,10 @@ class PlaylistGenerateResultPage extends HookConsumerWidget {
autoPlay: true,
);
},
child: Text(context.l10n.play),
),
FilledButton.tonalIcon(
icon: const Icon(SpotubeIcons.queueAdd),
label: Text(context.l10n.add_to_queue),
Button.primary(
leading: const Icon(SpotubeIcons.queueAdd),
onPressed: selectedTracks.value.isEmpty
? null
: () async {
@ -103,21 +105,25 @@ class PlaylistGenerateResultPage extends HookConsumerWidget {
),
);
if (context.mounted) {
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Text(
context.l10n.add_count_to_queue(
selectedTracks.value.length,
),
),
),
);
},
);
}
},
child: Text(context.l10n.add_to_queue),
),
FilledButton.tonalIcon(
icon: const Icon(SpotubeIcons.addFilled),
label: Text(context.l10n.create_a_playlist),
Button.primary(
leading: const Icon(SpotubeIcons.addFilled),
onPressed: selectedTracks.value.isEmpty
? null
: () async {
@ -138,10 +144,10 @@ class PlaylistGenerateResultPage extends HookConsumerWidget {
);
}
},
child: Text(context.l10n.create_a_playlist),
),
FilledButton.tonalIcon(
icon: const Icon(SpotubeIcons.playlistAdd),
label: Text(context.l10n.add_to_playlist),
Button.primary(
leading: const Icon(SpotubeIcons.playlistAdd),
onPressed: selectedTracks.value.isEmpty
? null
: () async {
@ -161,17 +167,22 @@ class PlaylistGenerateResultPage extends HookConsumerWidget {
);
if (context.mounted && hasAdded == true) {
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Text(
context.l10n.add_count_to_playlist(
selectedTracks.value.length,
),
),
),
);
},
);
}
},
child: Text(context.l10n.add_to_playlist),
)
],
),
@ -185,7 +196,7 @@ class PlaylistGenerateResultPage extends HookConsumerWidget {
selectedTracks.value.length,
),
),
ElevatedButton.icon(
Button.secondary(
onPressed: () {
if (isAllTrackSelected) {
selectedTracks.value = [];
@ -197,8 +208,8 @@ class PlaylistGenerateResultPage extends HookConsumerWidget {
[];
}
},
icon: const Icon(SpotubeIcons.selectionCheck),
label: Text(
leading: const Icon(SpotubeIcons.selectionCheck),
child: Text(
isAllTrackSelected
? context.l10n.deselect_all
: context.l10n.select_all,
@ -207,18 +218,21 @@ class PlaylistGenerateResultPage extends HookConsumerWidget {
],
),
const SizedBox(height: 8),
Card(
margin: const EdgeInsets.all(0),
child: SafeArea(
SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final track
in generatedPlaylist.asData?.value ?? [])
CheckboxListTile(
value: selectedTracks.value.contains(track.id),
Row(
spacing: 5,
children: [
Checkbox(
state: selectedTracks.value.contains(track.id)
? CheckboxState.checked
: CheckboxState.unchecked,
onChanged: (value) {
if (value == true) {
if (value == CheckboxState.checked) {
selectedTracks.value.add(track.id!);
} else {
selectedTracks.value.remove(track.id);
@ -226,15 +240,24 @@ class PlaylistGenerateResultPage extends HookConsumerWidget {
selectedTracks.value =
selectedTracks.value.toList();
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
dense: true,
title: SimpleTrackTile(track: track),
),
Expanded(
child: GestureDetector(
onTap: () {
selectedTracks.value.contains(track.id)
? selectedTracks.value.remove(track.id)
: selectedTracks.value.add(track.id!);
selectedTracks.value =
selectedTracks.value.toList();
},
child: SimpleTrackTile(track: track),
),
),
],
)
],
),
),
),
],
),
),

View File

@ -1,6 +1,8 @@
{
"ar": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -10,7 +12,9 @@
],
"bn": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -20,7 +24,9 @@
],
"ca": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -30,7 +36,9 @@
],
"cs": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -40,7 +48,9 @@
],
"de": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -50,7 +60,9 @@
],
"es": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -60,7 +72,9 @@
],
"eu": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -70,7 +84,9 @@
],
"fa": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -80,7 +96,9 @@
],
"fi": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -90,7 +108,9 @@
],
"fr": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -100,7 +120,9 @@
],
"hi": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -110,7 +132,9 @@
],
"id": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -120,7 +144,9 @@
],
"it": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -130,7 +156,9 @@
],
"ja": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -140,7 +168,9 @@
],
"ka": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -150,7 +180,9 @@
],
"ko": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -160,7 +192,9 @@
],
"ne": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -170,7 +204,9 @@
],
"nl": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -180,7 +216,9 @@
],
"pl": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -190,7 +228,9 @@
],
"pt": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -200,7 +240,9 @@
],
"ru": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -210,7 +252,9 @@
],
"th": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -220,7 +264,9 @@
],
"tr": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -230,7 +276,9 @@
],
"uk": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -240,7 +288,9 @@
],
"vi": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",
@ -250,7 +300,9 @@
],
"zh": [
"playlist",
"no_loop",
"generate",
"undo",
"download_all",
"add_all_to_playlist",