mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
feat: custom playlist generator
This commit is contained in:
parent
bed0d3bd70
commit
f4b0d134ca
@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:spotify/spotify.dart' hide Search;
|
import 'package:spotify/spotify.dart' hide Search;
|
||||||
import 'package:spotube/pages/home/home.dart';
|
import 'package:spotube/pages/home/home.dart';
|
||||||
|
import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart';
|
||||||
import 'package:spotube/pages/lyrics/mini_lyrics.dart';
|
import 'package:spotube/pages/lyrics/mini_lyrics.dart';
|
||||||
import 'package:spotube/pages/search/search.dart';
|
import 'package:spotube/pages/search/search.dart';
|
||||||
import 'package:spotube/pages/settings/blacklist.dart';
|
import 'package:spotube/pages/settings/blacklist.dart';
|
||||||
@ -21,6 +22,8 @@ import 'package:spotube/pages/root/root_app.dart';
|
|||||||
import 'package:spotube/pages/settings/settings.dart';
|
import 'package:spotube/pages/settings/settings.dart';
|
||||||
import 'package:spotube/pages/mobile_login/mobile_login.dart';
|
import 'package:spotube/pages/mobile_login/mobile_login.dart';
|
||||||
|
|
||||||
|
import '../pages/library/playlist_generate/playlist_generate_result.dart';
|
||||||
|
|
||||||
final rootNavigatorKey = Catcher.navigatorKey;
|
final rootNavigatorKey = Catcher.navigatorKey;
|
||||||
final shellRouteNavigatorKey = GlobalKey<NavigatorState>();
|
final shellRouteNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
final router = GoRouter(
|
final router = GoRouter(
|
||||||
@ -41,11 +44,27 @@ final router = GoRouter(
|
|||||||
const SpotubePage(child: SearchPage()),
|
const SpotubePage(child: SearchPage()),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: "/library",
|
path: "/library",
|
||||||
name: "Library",
|
name: "Library",
|
||||||
pageBuilder: (context, state) =>
|
pageBuilder: (context, state) =>
|
||||||
const SpotubePage(child: LibraryPage()),
|
const SpotubePage(child: LibraryPage()),
|
||||||
),
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: "generate",
|
||||||
|
pageBuilder: (context, state) =>
|
||||||
|
const SpotubePage(child: PlaylistGeneratorPage()),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: "result",
|
||||||
|
pageBuilder: (context, state) => SpotubePage(
|
||||||
|
child: PlaylistGenerateResultPage(
|
||||||
|
state:
|
||||||
|
state.extra as PlaylistGenerateResultRouteState,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: "/lyrics",
|
path: "/lyrics",
|
||||||
name: "Lyrics",
|
name: "Lyrics",
|
||||||
|
@ -80,4 +80,6 @@ abstract class SpotubeIcons {
|
|||||||
static const language = FeatherIcons.globe;
|
static const language = FeatherIcons.globe;
|
||||||
static const error = FeatherIcons.alertTriangle;
|
static const error = FeatherIcons.alertTriangle;
|
||||||
static const piped = FeatherIcons.cloud;
|
static const piped = FeatherIcons.cloud;
|
||||||
|
static const magic = Icons.auto_fix_high_outlined;
|
||||||
|
static const selectionCheck = Icons.checklist_rounded;
|
||||||
}
|
}
|
||||||
|
271
lib/components/library/playlist_generate/multi_select_field.dart
Normal file
271
lib/components/library/playlist_generate/multi_select_field.dart
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
||||||
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
|
||||||
|
class MultiSelectField<T> extends HookWidget {
|
||||||
|
final List<T> options;
|
||||||
|
final List<T> selectedOptions;
|
||||||
|
|
||||||
|
final Widget Function(T option, VoidCallback onSelect)? optionBuilder;
|
||||||
|
final Widget Function(T option)? selectedOptionBuilder;
|
||||||
|
final ValueChanged<List<T>> onSelected;
|
||||||
|
|
||||||
|
final Widget? dialogTitle;
|
||||||
|
|
||||||
|
final Object Function(T option) getValueForOption;
|
||||||
|
|
||||||
|
final Widget label;
|
||||||
|
|
||||||
|
final String? helperText;
|
||||||
|
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
const MultiSelectField({
|
||||||
|
Key? key,
|
||||||
|
required this.options,
|
||||||
|
required this.selectedOptions,
|
||||||
|
required this.getValueForOption,
|
||||||
|
required this.label,
|
||||||
|
this.optionBuilder,
|
||||||
|
this.selectedOptionBuilder,
|
||||||
|
required this.onSelected,
|
||||||
|
this.dialogTitle,
|
||||||
|
this.helperText,
|
||||||
|
this.enabled = true,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
Widget defaultSelectedOptionBuilder(T option) {
|
||||||
|
return Chip(
|
||||||
|
label: Text(option.toString()),
|
||||||
|
onDeleted: () {
|
||||||
|
onSelected(
|
||||||
|
selectedOptions.where((e) => e != getValueForOption(option)).toList(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
MaterialButton(
|
||||||
|
elevation: 0,
|
||||||
|
focusElevation: 0,
|
||||||
|
hoverElevation: 0,
|
||||||
|
disabledElevation: 0,
|
||||||
|
highlightElevation: 0,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 22),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(15),
|
||||||
|
side: BorderSide(
|
||||||
|
color: enabled
|
||||||
|
? theme.colorScheme.onSurface
|
||||||
|
: theme.colorScheme.onSurface.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
mouseCursor: MaterialStateMouseCursor.textable,
|
||||||
|
onPressed: !enabled
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final selected = await showDialog<List<T>>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return _MultiSelectDialog<T>(
|
||||||
|
dialogTitle: dialogTitle,
|
||||||
|
options: options,
|
||||||
|
getValueForOption: getValueForOption,
|
||||||
|
optionBuilder: optionBuilder,
|
||||||
|
initialSelection: selectedOptions,
|
||||||
|
helperText: helperText,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (selected != null) {
|
||||||
|
onSelected(selected);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
|
child: DefaultTextStyle(
|
||||||
|
style: theme.textTheme.titleMedium!,
|
||||||
|
child: label,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (helperText != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Text(
|
||||||
|
helperText!,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Wrap(
|
||||||
|
children: [
|
||||||
|
...selectedOptions.map(
|
||||||
|
(option) => Padding(
|
||||||
|
padding: const EdgeInsets.all(4.0),
|
||||||
|
child: (selectedOptionBuilder ??
|
||||||
|
defaultSelectedOptionBuilder)(option),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MultiSelectDialog<T> extends HookWidget {
|
||||||
|
final Widget? dialogTitle;
|
||||||
|
final List<T> options;
|
||||||
|
final Widget Function(T option, VoidCallback onSelect)? optionBuilder;
|
||||||
|
final Object Function(T option) getValueForOption;
|
||||||
|
final List<T> initialSelection;
|
||||||
|
final String? helperText;
|
||||||
|
|
||||||
|
const _MultiSelectDialog({
|
||||||
|
Key? key,
|
||||||
|
required this.dialogTitle,
|
||||||
|
required this.options,
|
||||||
|
required this.getValueForOption,
|
||||||
|
this.optionBuilder,
|
||||||
|
this.initialSelection = const [],
|
||||||
|
this.helperText,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
final selected = useState(initialSelection.map(getValueForOption));
|
||||||
|
|
||||||
|
final searchController = useTextEditingController();
|
||||||
|
|
||||||
|
// creates render update
|
||||||
|
useValueListenable(searchController);
|
||||||
|
|
||||||
|
final filteredOptions = useMemoized(
|
||||||
|
() {
|
||||||
|
if (searchController.text.isEmpty) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
.map((e) => (
|
||||||
|
weightedRatio(
|
||||||
|
getValueForOption(e).toString(), searchController.text),
|
||||||
|
e
|
||||||
|
))
|
||||||
|
.sorted((a, b) => b.$1.compareTo(a.$1))
|
||||||
|
.where((e) => e.$1 > 50)
|
||||||
|
.map((e) => e.$2)
|
||||||
|
.toList();
|
||||||
|
},
|
||||||
|
[searchController.text, options, getValueForOption],
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget defaultOptionBuilder(T option, VoidCallback onSelect) {
|
||||||
|
final isSelected = selected.value.contains(getValueForOption(option));
|
||||||
|
return ChoiceChip(
|
||||||
|
label: Text("${!isSelected ? " " : ""}${option.toString()}"),
|
||||||
|
selected: isSelected,
|
||||||
|
side: BorderSide.none,
|
||||||
|
onSelected: (_) {
|
||||||
|
onSelect();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: dialogTitle ?? const Text('Select'),
|
||||||
|
contentPadding: mediaQuery.isSm ? const EdgeInsets.all(16) : null,
|
||||||
|
insetPadding: const EdgeInsets.all(16),
|
||||||
|
actions: [
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: Text(context.l10n.cancel),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(
|
||||||
|
context,
|
||||||
|
options
|
||||||
|
.where(
|
||||||
|
(option) =>
|
||||||
|
selected.value.contains(getValueForOption(option)),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Text(context.l10n.done),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
content: SizedBox(
|
||||||
|
height: mediaQuery.size.height * 0.5,
|
||||||
|
width: 400,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: context.l10n.search,
|
||||||
|
prefixIcon: const Icon(SpotubeIcons.search),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(15),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 5,
|
||||||
|
runSpacing: 5,
|
||||||
|
children: [
|
||||||
|
...filteredOptions.map(
|
||||||
|
(option) => Padding(
|
||||||
|
padding: const EdgeInsets.all(4.0),
|
||||||
|
child: (optionBuilder ?? defaultOptionBuilder)(
|
||||||
|
option,
|
||||||
|
() {
|
||||||
|
final value = getValueForOption(option);
|
||||||
|
if (selected.value.contains(value)) {
|
||||||
|
selected.value = selected.value
|
||||||
|
.where((e) => e != value)
|
||||||
|
.toList();
|
||||||
|
} else {
|
||||||
|
selected.value = [...selected.value, value];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (helperText != null)
|
||||||
|
Text(
|
||||||
|
helperText!,
|
||||||
|
style: Theme.of(context).textTheme.labelMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,124 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
|
||||||
|
enum SelectedItemDisplayType {
|
||||||
|
wrap,
|
||||||
|
list,
|
||||||
|
}
|
||||||
|
|
||||||
|
class SeedsMultiAutocomplete<T extends Object> extends HookWidget {
|
||||||
|
final ValueNotifier<List<T>> seeds;
|
||||||
|
|
||||||
|
final FutureOr<List<T>> Function(TextEditingValue textEditingValue)
|
||||||
|
fetchSeeds;
|
||||||
|
final Widget Function(T option, ValueChanged<T> onSelected)
|
||||||
|
autocompleteOptionBuilder;
|
||||||
|
final Widget Function(T option) selectedSeedBuilder;
|
||||||
|
final String Function(T option) displayStringForOption;
|
||||||
|
|
||||||
|
final InputDecoration? inputDecoration;
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
final SelectedItemDisplayType selectedItemDisplayType;
|
||||||
|
|
||||||
|
const SeedsMultiAutocomplete({
|
||||||
|
Key? key,
|
||||||
|
required this.seeds,
|
||||||
|
required this.fetchSeeds,
|
||||||
|
required this.autocompleteOptionBuilder,
|
||||||
|
required this.displayStringForOption,
|
||||||
|
required this.selectedSeedBuilder,
|
||||||
|
this.inputDecoration,
|
||||||
|
this.enabled = true,
|
||||||
|
this.selectedItemDisplayType = SelectedItemDisplayType.wrap,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
useValueListenable(seeds);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final seedController = useTextEditingController();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
LayoutBuilder(builder: (context, constrains) {
|
||||||
|
return Autocomplete<T>(
|
||||||
|
optionsBuilder: (textEditingValue) async {
|
||||||
|
if (textEditingValue.text.isEmpty) return [];
|
||||||
|
return fetchSeeds(textEditingValue);
|
||||||
|
},
|
||||||
|
onSelected: (value) {
|
||||||
|
seeds.value = [...seeds.value, value];
|
||||||
|
seedController.clear();
|
||||||
|
},
|
||||||
|
optionsViewBuilder: (context, onSelected, options) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: constrains.maxWidth,
|
||||||
|
),
|
||||||
|
child: Card(
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: options.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final option = options.elementAt(index);
|
||||||
|
return autocompleteOptionBuilder(option, onSelected);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
displayStringForOption: displayStringForOption,
|
||||||
|
fieldViewBuilder: (
|
||||||
|
context,
|
||||||
|
textEditingController,
|
||||||
|
focusNode,
|
||||||
|
onFieldSubmitted,
|
||||||
|
) {
|
||||||
|
return TextFormField(
|
||||||
|
controller: seedController,
|
||||||
|
onChanged: (value) => textEditingController.text = value,
|
||||||
|
focusNode: focusNode,
|
||||||
|
onFieldSubmitted: (_) => onFieldSubmitted(),
|
||||||
|
enabled: enabled,
|
||||||
|
decoration: inputDecoration,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
switch (selectedItemDisplayType) {
|
||||||
|
SelectedItemDisplayType.wrap => Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: seeds.value.map(selectedSeedBuilder).toList(),
|
||||||
|
),
|
||||||
|
SelectedItemDisplayType.list => Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (final seed in seeds.value) ...[
|
||||||
|
selectedSeedBuilder(seed),
|
||||||
|
if (seeds.value.length > 1 && seed != seeds.value.last)
|
||||||
|
Divider(
|
||||||
|
color: theme.colorScheme.primaryContainer,
|
||||||
|
height: 1,
|
||||||
|
indent: 12,
|
||||||
|
endIndent: 12,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,46 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.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/utils/type_conversion_utils.dart';
|
||||||
|
|
||||||
|
class SimpleTrackTile extends HookWidget {
|
||||||
|
final Track track;
|
||||||
|
final VoidCallback? onDelete;
|
||||||
|
const SimpleTrackTile({
|
||||||
|
Key? key,
|
||||||
|
required this.track,
|
||||||
|
this.onDelete,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListTile(
|
||||||
|
leading: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: UniversalImage(
|
||||||
|
path: TypeConversionUtils.image_X_UrlString(
|
||||||
|
track.album?.images,
|
||||||
|
placeholder: ImagePlaceholder.artist,
|
||||||
|
),
|
||||||
|
height: 40,
|
||||||
|
width: 40,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
horizontalTitleGap: 10,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
title: Text(track.name!),
|
||||||
|
trailing: onDelete == null
|
||||||
|
? null
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(SpotubeIcons.close),
|
||||||
|
onPressed: onDelete,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
track.artists?.map((e) => e.name).join(", ") ?? track.album?.name ?? "",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart' hide Image;
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
@ -11,8 +12,6 @@ import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'
|
|||||||
import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart';
|
import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart';
|
||||||
import 'package:spotube/components/playlist/playlist_card.dart';
|
import 'package:spotube/components/playlist/playlist_card.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/hooks/use_breakpoint_value.dart';
|
|
||||||
import 'package:spotube/hooks/use_breakpoints.dart';
|
|
||||||
import 'package:spotube/provider/authentication_provider.dart';
|
import 'package:spotube/provider/authentication_provider.dart';
|
||||||
import 'package:spotube/services/queries/queries.dart';
|
import 'package:spotube/services/queries/queries.dart';
|
||||||
|
|
||||||
@ -22,11 +21,7 @@ class UserPlaylists extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final searchText = useState('');
|
final searchText = useState('');
|
||||||
final breakpoint = useBreakpoints();
|
|
||||||
final spacing = useBreakpointValue<double>(
|
|
||||||
sm: 0,
|
|
||||||
others: 20,
|
|
||||||
);
|
|
||||||
final auth = ref.watch(AuthenticationNotifier.provider);
|
final auth = ref.watch(AuthenticationNotifier.provider);
|
||||||
|
|
||||||
final playlistsQuery = useQueries.playlist.ofMine(ref);
|
final playlistsQuery = useQueries.playlist.ofMine(ref);
|
||||||
@ -103,10 +98,18 @@ class UserPlaylists extends HookConsumerWidget {
|
|||||||
alignment: WrapAlignment.center,
|
alignment: WrapAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: const [
|
children: [
|
||||||
SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
PlaylistCreateDialog(),
|
const PlaylistCreateDialog(),
|
||||||
SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(SpotubeIcons.magic),
|
||||||
|
label: Text(context.l10n.generate_playlist),
|
||||||
|
onPressed: () {
|
||||||
|
GoRouter.of(context).push("/library/generate");
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
...playlists.map((playlist) => PlaylistCard(playlist))
|
...playlists.map((playlist) => PlaylistCard(playlist))
|
||||||
|
@ -2,110 +2,124 @@ import 'package:fl_query_hooks/fl_query_hooks.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:spotify/spotify.dart';
|
||||||
|
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/provider/spotify_provider.dart';
|
import 'package:spotube/provider/spotify_provider.dart';
|
||||||
|
|
||||||
class PlaylistCreateDialog extends HookConsumerWidget {
|
class PlaylistCreateDialog extends HookConsumerWidget {
|
||||||
const PlaylistCreateDialog({Key? key}) : super(key: key);
|
const PlaylistCreateDialog({Key? key}) : super(key: key);
|
||||||
|
|
||||||
@override
|
showPlaylistDialog(BuildContext context, SpotifyApi spotify) {
|
||||||
Widget build(BuildContext context, ref) {
|
showDialog(
|
||||||
final spotify = ref.watch(spotifyProvider);
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return HookBuilder(builder: (context) {
|
||||||
|
final playlistName = useTextEditingController();
|
||||||
|
final description = useTextEditingController();
|
||||||
|
final public = useState(false);
|
||||||
|
final collaborative = useState(false);
|
||||||
|
final client = useQueryClient();
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
|
||||||
return FilledButton.tonalIcon(
|
onCreate() async {
|
||||||
style: FilledButton.styleFrom(
|
if (playlistName.text.isEmpty) return;
|
||||||
foregroundColor: Theme.of(context).colorScheme.primary,
|
final me = await spotify.me.get();
|
||||||
),
|
await spotify.playlists.createPlaylist(
|
||||||
icon: const Icon(SpotubeIcons.addFilled),
|
me.id!,
|
||||||
label: Text(context.l10n.create_playlist),
|
playlistName.text,
|
||||||
onPressed: () {
|
collaborative: collaborative.value,
|
||||||
showDialog(
|
public: public.value,
|
||||||
context: context,
|
description: description.text,
|
||||||
builder: (context) {
|
);
|
||||||
return HookBuilder(builder: (context) {
|
await client
|
||||||
final playlistName = useTextEditingController();
|
.getQuery(
|
||||||
final description = useTextEditingController();
|
"current-user-playlists",
|
||||||
final public = useState(false);
|
)
|
||||||
final collaborative = useState(false);
|
?.refresh();
|
||||||
final client = useQueryClient();
|
navigator.pop();
|
||||||
final navigator = Navigator.of(context);
|
}
|
||||||
|
|
||||||
onCreate() async {
|
return AlertDialog(
|
||||||
if (playlistName.text.isEmpty) return;
|
title: Text(context.l10n.create_a_playlist),
|
||||||
final me = await spotify.me.get();
|
actions: [
|
||||||
await spotify.playlists.createPlaylist(
|
OutlinedButton(
|
||||||
me.id!,
|
child: Text(context.l10n.cancel),
|
||||||
playlistName.text,
|
onPressed: () {
|
||||||
collaborative: collaborative.value,
|
Navigator.pop(context);
|
||||||
public: public.value,
|
},
|
||||||
description: description.text,
|
),
|
||||||
);
|
FilledButton(
|
||||||
await client
|
onPressed: onCreate,
|
||||||
.getQuery(
|
child: Text(context.l10n.create),
|
||||||
"current-user-playlists",
|
),
|
||||||
)
|
],
|
||||||
?.refresh();
|
content: Container(
|
||||||
navigator.pop();
|
width: MediaQuery.of(context).size.width,
|
||||||
}
|
constraints: const BoxConstraints(maxWidth: 500),
|
||||||
|
child: ListView(
|
||||||
return AlertDialog(
|
shrinkWrap: true,
|
||||||
title: Text(context.l10n.create_a_playlist),
|
children: [
|
||||||
actions: [
|
TextField(
|
||||||
OutlinedButton(
|
controller: playlistName,
|
||||||
child: Text(context.l10n.cancel),
|
decoration: InputDecoration(
|
||||||
onPressed: () {
|
hintText: context.l10n.name_of_playlist,
|
||||||
Navigator.pop(context);
|
labelText: context.l10n.name_of_playlist,
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
FilledButton(
|
const SizedBox(height: 10),
|
||||||
onPressed: onCreate,
|
TextField(
|
||||||
child: Text(context.l10n.create),
|
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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, ref) {
|
||||||
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
final spotify = ref.watch(spotifyProvider);
|
||||||
|
|
||||||
|
if (mediaQuery.isSm) {
|
||||||
|
return ElevatedButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
child: 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),
|
||||||
|
onPressed: () => showPlaylistDialog(context, spotify));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
25
lib/extensions/constrains.dart
Normal file
25
lib/extensions/constrains.dart
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
extension ContainerBreakpoints on BoxConstraints {
|
||||||
|
bool get isSm => biggest.width <= 640;
|
||||||
|
bool get isMd => biggest.width > 640 && biggest.width <= 768;
|
||||||
|
bool get isLg => biggest.width > 768 && biggest.width <= 1024;
|
||||||
|
bool get isXl => biggest.width > 1024 && biggest.width <= 1280;
|
||||||
|
bool get is2Xl => biggest.width > 1280 && biggest.width <= 1536;
|
||||||
|
|
||||||
|
bool get mdAndUp => isMd || isLg || isXl || is2Xl;
|
||||||
|
bool get lgAndUp => isLg || isXl || is2Xl;
|
||||||
|
bool get xlAndUp => isXl || is2Xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ScreenBreakpoints on MediaQueryData {
|
||||||
|
bool get isSm => size.width <= 640;
|
||||||
|
bool get isMd => size.width > 640 && size.width <= 768;
|
||||||
|
bool get isLg => size.width > 768 && size.width <= 1024;
|
||||||
|
bool get isXl => size.width > 1024 && size.width <= 1280;
|
||||||
|
bool get is2Xl => size.width > 1280 && size.width <= 1536;
|
||||||
|
|
||||||
|
bool get mdAndUp => isMd || isLg || isXl || is2Xl;
|
||||||
|
bool get lgAndUp => isLg || isXl || is2Xl;
|
||||||
|
bool get xlAndUp => isXl || is2Xl;
|
||||||
|
}
|
@ -184,5 +184,6 @@
|
|||||||
"step_4_steps": "Paste the copied \"sp_dc\" and \"sp_key\" values in the respective fields",
|
"step_4_steps": "Paste the copied \"sp_dc\" and \"sp_key\" values in the respective fields",
|
||||||
"something_went_wrong": "Something went wrong",
|
"something_went_wrong": "Something went wrong",
|
||||||
"piped_instance": "Piped Server Instance",
|
"piped_instance": "Piped Server Instance",
|
||||||
"piped_description": "The Piped server instance to use for track matching\nSome of them might not work well. So use at your own risk"
|
"piped_description": "The Piped server instance to use for track matching\nSome of them might not work well. So use at your own risk",
|
||||||
|
"generate_playlist": "Generate Playlist"
|
||||||
}
|
}
|
@ -119,7 +119,6 @@ Future<void> main(List<String> rawArgs) async {
|
|||||||
enableApplicationParameters: false,
|
enableApplicationParameters: false,
|
||||||
),
|
),
|
||||||
FileHandler(await getLogsPath(), printLogs: false),
|
FileHandler(await getLogsPath(), printLogs: false),
|
||||||
CustomToastHandler(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
releaseConfig: CatcherOptions(
|
releaseConfig: CatcherOptions(
|
||||||
|
296
lib/pages/library/playlist_generate/playlist_generate.dart
Normal file
296
lib/pages/library/playlist_generate/playlist_generate.dart
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
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:spotify/spotify.dart';
|
||||||
|
import 'package:spotube/collections/spotify_markets.dart';
|
||||||
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
|
import 'package:spotube/components/library/playlist_generate/multi_select_field.dart';
|
||||||
|
import 'package:spotube/components/library/playlist_generate/seeds_multi_autocomplete.dart';
|
||||||
|
import 'package:spotube/components/library/playlist_generate/simple_track_tile.dart';
|
||||||
|
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||||
|
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
||||||
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart';
|
||||||
|
import 'package:spotube/provider/spotify_provider.dart';
|
||||||
|
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||||
|
import 'package:spotube/services/queries/queries.dart';
|
||||||
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
|
|
||||||
|
class PlaylistGeneratorPage extends HookConsumerWidget {
|
||||||
|
const PlaylistGeneratorPage({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, ref) {
|
||||||
|
final spotify = ref.watch(spotifyProvider);
|
||||||
|
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final textTheme = theme.textTheme;
|
||||||
|
final preferences = ref.watch(userPreferencesProvider);
|
||||||
|
|
||||||
|
final genresCollection = useQueries.category.genreSeeds(ref);
|
||||||
|
|
||||||
|
final limit = useValueNotifier<int>(10);
|
||||||
|
final market = useValueNotifier<String>(preferences.recommendationMarket);
|
||||||
|
|
||||||
|
final genres = useState<List<String>>([]);
|
||||||
|
final artists = useState<List<Artist>>([]);
|
||||||
|
final tracks = useState<List<Track>>([]);
|
||||||
|
|
||||||
|
final enabled =
|
||||||
|
genres.value.length + artists.value.length + tracks.value.length < 5;
|
||||||
|
|
||||||
|
final leftSeedCount =
|
||||||
|
5 - genres.value.length - artists.value.length - tracks.value.length;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: PageWindowTitleBar(
|
||||||
|
leading: const BackButton(),
|
||||||
|
title: Text(context.l10n.generate_playlist),
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
ValueListenableBuilder(
|
||||||
|
valueListenable: limit,
|
||||||
|
builder: (context, value, child) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"Number of tracks to generate",
|
||||||
|
style: textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
value.round().toString(),
|
||||||
|
style: textTheme.bodyLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.primaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: value.toDouble(),
|
||||||
|
min: 10,
|
||||||
|
max: 100,
|
||||||
|
divisions: 9,
|
||||||
|
label: value.round().toString(),
|
||||||
|
onChanged: (value) {
|
||||||
|
limit.value = value.round();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ValueListenableBuilder(
|
||||||
|
valueListenable: market,
|
||||||
|
builder: (context, value, _) {
|
||||||
|
return DropdownMenu<String>(
|
||||||
|
hintText: "Select a country",
|
||||||
|
dropdownMenuEntries: spotifyMarkets
|
||||||
|
.map(
|
||||||
|
(country) => DropdownMenuEntry(
|
||||||
|
value: country.first,
|
||||||
|
label: country.last,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
initialSelection: market.value,
|
||||||
|
onSelected: (value) {
|
||||||
|
market.value = value!;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
MultiSelectField<String>(
|
||||||
|
options: genresCollection.data ?? [],
|
||||||
|
selectedOptions: genres.value,
|
||||||
|
getValueForOption: (option) => option,
|
||||||
|
onSelected: (value) {
|
||||||
|
genres.value = value;
|
||||||
|
},
|
||||||
|
dialogTitle: const Text("Select genres"),
|
||||||
|
label: const Text("Add genres"),
|
||||||
|
helperText: "Select up to $leftSeedCount genres",
|
||||||
|
enabled: enabled,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SeedsMultiAutocomplete<Artist>(
|
||||||
|
seeds: artists,
|
||||||
|
enabled: enabled,
|
||||||
|
inputDecoration: InputDecoration(
|
||||||
|
labelText: "Artists",
|
||||||
|
labelStyle: textTheme.titleMedium,
|
||||||
|
helperText: "Select up to $leftSeedCount artists",
|
||||||
|
),
|
||||||
|
fetchSeeds: (textEditingValue) => spotify.search
|
||||||
|
.get(
|
||||||
|
textEditingValue.text,
|
||||||
|
types: [SearchType.artist],
|
||||||
|
)
|
||||||
|
.first(6)
|
||||||
|
.then(
|
||||||
|
(v) => List.castFrom<dynamic, Artist>(
|
||||||
|
v.expand((e) => e.items ?? []).toList(),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
(element) => artists.value
|
||||||
|
.none((artist) => element.id == artist.id),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
autocompleteOptionBuilder: (option, onSelected) => ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundImage: UniversalImage.imageProvider(
|
||||||
|
TypeConversionUtils.image_X_UrlString(
|
||||||
|
option.images,
|
||||||
|
placeholder: ImagePlaceholder.artist,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
horizontalTitleGap: 20,
|
||||||
|
title: Text(option.name!),
|
||||||
|
subtitle: option.genres?.isNotEmpty != true
|
||||||
|
? null
|
||||||
|
: Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
runSpacing: 4,
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
).toList(),
|
||||||
|
),
|
||||||
|
onTap: () => onSelected(option),
|
||||||
|
),
|
||||||
|
displayStringForOption: (option) => option.name!,
|
||||||
|
selectedSeedBuilder: (artist) => Chip(
|
||||||
|
avatar: CircleAvatar(
|
||||||
|
backgroundImage: UniversalImage.imageProvider(
|
||||||
|
TypeConversionUtils.image_X_UrlString(
|
||||||
|
artist.images,
|
||||||
|
placeholder: ImagePlaceholder.artist,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
label: Text(artist.name!),
|
||||||
|
onDeleted: () {
|
||||||
|
artists.value = [
|
||||||
|
...artists.value
|
||||||
|
..removeWhere((element) => element.id == artist.id)
|
||||||
|
];
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SeedsMultiAutocomplete<Track>(
|
||||||
|
seeds: tracks,
|
||||||
|
enabled: enabled,
|
||||||
|
selectedItemDisplayType: SelectedItemDisplayType.list,
|
||||||
|
inputDecoration: InputDecoration(
|
||||||
|
labelText: "Tracks",
|
||||||
|
labelStyle: textTheme.titleMedium,
|
||||||
|
helperText: "Select up to $leftSeedCount tracks",
|
||||||
|
),
|
||||||
|
fetchSeeds: (textEditingValue) => spotify.search
|
||||||
|
.get(
|
||||||
|
textEditingValue.text,
|
||||||
|
types: [SearchType.track],
|
||||||
|
)
|
||||||
|
.first(6)
|
||||||
|
.then(
|
||||||
|
(v) => List.castFrom<dynamic, Track>(
|
||||||
|
v.expand((e) => e.items ?? []).toList(),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
(element) => tracks.value
|
||||||
|
.none((track) => element.id == track.id),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
autocompleteOptionBuilder: (option, onSelected) => ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundImage: UniversalImage.imageProvider(
|
||||||
|
TypeConversionUtils.image_X_UrlString(
|
||||||
|
option.album?.images,
|
||||||
|
placeholder: ImagePlaceholder.artist,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
horizontalTitleGap: 20,
|
||||||
|
title: Text(option.name!),
|
||||||
|
subtitle: Text(
|
||||||
|
option.artists?.map((e) => e.name).join(", ") ??
|
||||||
|
option.album?.name ??
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
onTap: () => onSelected(option),
|
||||||
|
),
|
||||||
|
displayStringForOption: (option) => option.name!,
|
||||||
|
selectedSeedBuilder: (option) => SimpleTrackTile(
|
||||||
|
track: option,
|
||||||
|
onDelete: () {
|
||||||
|
tracks.value = [
|
||||||
|
...tracks.value
|
||||||
|
..removeWhere((element) => element.id == option.id)
|
||||||
|
];
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
FilledButton.icon(
|
||||||
|
icon: const Icon(SpotubeIcons.magic),
|
||||||
|
label: Text("Generate"),
|
||||||
|
onPressed: () {
|
||||||
|
final PlaylistGenerateResultRouteState routeState = (
|
||||||
|
seeds: (
|
||||||
|
artists: artists.value.map((a) => a.id!).toList(),
|
||||||
|
tracks: tracks.value.map((t) => t.id!).toList(),
|
||||||
|
genres: genres.value
|
||||||
|
),
|
||||||
|
market: market.value,
|
||||||
|
limit: limit.value,
|
||||||
|
max: null,
|
||||||
|
min: null,
|
||||||
|
target: null,
|
||||||
|
);
|
||||||
|
GoRouter.of(context).push(
|
||||||
|
"/library/generate/result",
|
||||||
|
extra: routeState,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,202 @@
|
|||||||
|
import 'package:fl_query_hooks/fl_query_hooks.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
|
import 'package:spotube/components/library/playlist_generate/simple_track_tile.dart';
|
||||||
|
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
||||||
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
|
import 'package:spotube/services/queries/playlist.dart';
|
||||||
|
import 'package:spotube/services/queries/queries.dart';
|
||||||
|
|
||||||
|
typedef PlaylistGenerateResultRouteState = ({
|
||||||
|
({List<String> tracks, List<String> artists, List<String> genres})? seeds,
|
||||||
|
RecommendationParameters? min,
|
||||||
|
RecommendationParameters? max,
|
||||||
|
RecommendationParameters? target,
|
||||||
|
int limit,
|
||||||
|
String? market,
|
||||||
|
});
|
||||||
|
|
||||||
|
class PlaylistGenerateResultPage extends HookConsumerWidget {
|
||||||
|
final PlaylistGenerateResultRouteState state;
|
||||||
|
|
||||||
|
const PlaylistGenerateResultPage({
|
||||||
|
Key? key,
|
||||||
|
required this.state,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, ref) {
|
||||||
|
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||||
|
final (:seeds, :min, :max, :target, :limit, :market) = state;
|
||||||
|
|
||||||
|
final queryClient = useQueryClient();
|
||||||
|
final generatedPlaylist = useQueries.playlist.generate(
|
||||||
|
ref,
|
||||||
|
seeds: seeds,
|
||||||
|
min: min,
|
||||||
|
max: max,
|
||||||
|
target: target,
|
||||||
|
limit: limit,
|
||||||
|
market: market,
|
||||||
|
);
|
||||||
|
|
||||||
|
final selectedTracks = useState<List<String>>(
|
||||||
|
generatedPlaylist.data?.map((e) => e.id!).toList() ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
if (generatedPlaylist.data != null) {
|
||||||
|
selectedTracks.value =
|
||||||
|
generatedPlaylist.data!.map((e) => e.id!).toList();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [generatedPlaylist.data]);
|
||||||
|
|
||||||
|
final isAllTrackSelected =
|
||||||
|
selectedTracks.value.length == (generatedPlaylist.data?.length ?? 0);
|
||||||
|
|
||||||
|
return WillPopScope(
|
||||||
|
onWillPop: () async {
|
||||||
|
queryClient.cache.removeQuery(generatedPlaylist);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: const PageWindowTitleBar(leading: BackButton()),
|
||||||
|
body: generatedPlaylist.isLoading
|
||||||
|
? const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
Text("Generating your custom playlist..."),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: ListView(
|
||||||
|
children: [
|
||||||
|
GridView(
|
||||||
|
gridDelegate:
|
||||||
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
mainAxisExtent: 32,
|
||||||
|
),
|
||||||
|
shrinkWrap: true,
|
||||||
|
children: [
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
icon: const Icon(SpotubeIcons.play),
|
||||||
|
label: Text(context.l10n.play),
|
||||||
|
onPressed: selectedTracks.value.isEmpty
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
await playlistNotifier.load(
|
||||||
|
generatedPlaylist.data!.where(
|
||||||
|
(e) =>
|
||||||
|
selectedTracks.value.contains(e.id!),
|
||||||
|
),
|
||||||
|
autoPlay: true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
icon: const Icon(SpotubeIcons.queueAdd),
|
||||||
|
label: Text(context.l10n.add_to_queue),
|
||||||
|
onPressed: selectedTracks.value.isEmpty
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
await playlistNotifier.addTracks(
|
||||||
|
generatedPlaylist.data!.where(
|
||||||
|
(e) =>
|
||||||
|
selectedTracks.value.contains(e.id!),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
context.l10n.add_count_to_queue(
|
||||||
|
selectedTracks.value.length,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
icon: const Icon(SpotubeIcons.addFilled),
|
||||||
|
label: Text(context.l10n.create_a_playlist),
|
||||||
|
onPressed:
|
||||||
|
selectedTracks.value.isEmpty ? null : () {},
|
||||||
|
),
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
icon: const Icon(SpotubeIcons.playlistAdd),
|
||||||
|
label: Text(context.l10n.add_to_playlist),
|
||||||
|
onPressed:
|
||||||
|
selectedTracks.value.isEmpty ? null : () {},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (generatedPlaylist.data != null)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
if (isAllTrackSelected) {
|
||||||
|
selectedTracks.value = [];
|
||||||
|
} else {
|
||||||
|
selectedTracks.value = generatedPlaylist.data
|
||||||
|
?.map((e) => e.id!)
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(SpotubeIcons.selectionCheck),
|
||||||
|
label: Text(
|
||||||
|
isAllTrackSelected ? "Deselect all" : "Select all",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Card(
|
||||||
|
margin: const EdgeInsets.all(0),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (final track in generatedPlaylist.data ?? [])
|
||||||
|
CheckboxListTile(
|
||||||
|
value: selectedTracks.value.contains(track.id),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == true) {
|
||||||
|
selectedTracks.value.add(track.id!);
|
||||||
|
} else {
|
||||||
|
selectedTracks.value.remove(track.id);
|
||||||
|
}
|
||||||
|
selectedTracks.value =
|
||||||
|
selectedTracks.value.toList();
|
||||||
|
},
|
||||||
|
controlAffinity:
|
||||||
|
ListTileControlAffinity.leading,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
title: SimpleTrackTile(track: track),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:spotify/spotify.dart';
|
||||||
|
|
||||||
class CustomSpotifyEndpoints {
|
class CustomSpotifyEndpoints {
|
||||||
static const _baseUrl = 'https://api.spotify.com/v1';
|
static const _baseUrl = 'https://api.spotify.com/v1';
|
||||||
@ -80,4 +81,83 @@ class CustomSpotifyEndpoints {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<String>> listGenreSeeds() async {
|
||||||
|
final res = await _client.get(
|
||||||
|
Uri.parse("$_baseUrl/recommendations/available-genre-seeds"),
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
if (accessToken.isNotEmpty) "authorization": "Bearer $accessToken",
|
||||||
|
"accept": "application/json",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
final body = jsonDecode(res.body);
|
||||||
|
return List<String>.from(body["genres"] ?? []);
|
||||||
|
} else {
|
||||||
|
throw Exception(
|
||||||
|
'[CustomSpotifyEndpoints.listGenreSeeds]: Failed to get genre seeds'
|
||||||
|
'\nStatus code: ${res.statusCode}'
|
||||||
|
'\nBody: ${res.body}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addList(
|
||||||
|
Map<String, String> parameters, String key, Iterable<String> paramList) {
|
||||||
|
if (paramList.isNotEmpty) {
|
||||||
|
parameters[key] = paramList.join(',');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addTunableTrackMap(
|
||||||
|
Map<String, String> parameters, Map<String, num>? tunableTrackMap) {
|
||||||
|
if (tunableTrackMap != null) {
|
||||||
|
parameters.addAll(tunableTrackMap.map<String, String>((k, v) =>
|
||||||
|
MapEntry(k, v is int ? v.toString() : v.toStringAsFixed(2))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Track>> getRecommendations({
|
||||||
|
Iterable<String>? seedArtists,
|
||||||
|
Iterable<String>? seedGenres,
|
||||||
|
Iterable<String>? seedTracks,
|
||||||
|
int limit = 20,
|
||||||
|
String? market,
|
||||||
|
Map<String, num>? max,
|
||||||
|
Map<String, num>? min,
|
||||||
|
Map<String, num>? target,
|
||||||
|
}) async {
|
||||||
|
assert(limit >= 1 && limit <= 100, 'limit should be 1 <= limit <= 100');
|
||||||
|
final seedsNum = (seedArtists?.length ?? 0) +
|
||||||
|
(seedGenres?.length ?? 0) +
|
||||||
|
(seedTracks?.length ?? 0);
|
||||||
|
assert(
|
||||||
|
seedsNum >= 1 && seedsNum <= 5,
|
||||||
|
'Up to 5 seed values may be provided in any combination of seed_artists,'
|
||||||
|
' seed_tracks and seed_genres.');
|
||||||
|
final parameters = <String, String>{'limit': limit.toString()};
|
||||||
|
final _ = {
|
||||||
|
'seed_artists': seedArtists,
|
||||||
|
'seed_genres': seedGenres,
|
||||||
|
'seed_tracks': seedTracks
|
||||||
|
}.forEach((key, list) => _addList(parameters, key, list!));
|
||||||
|
if (market != null) parameters['market'] = market;
|
||||||
|
[min, max, target].forEach((map) => _addTunableTrackMap(parameters, map));
|
||||||
|
final pathQuery =
|
||||||
|
"$_baseUrl/recommendations?${parameters.entries.map((e) => '${e.key}=${e.value}').join('&')}";
|
||||||
|
final res = await _client.get(
|
||||||
|
Uri.parse(pathQuery),
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
if (accessToken.isNotEmpty) "authorization": "Bearer $accessToken",
|
||||||
|
"accept": "application/json",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final result = jsonDecode(res.body);
|
||||||
|
return List.castFrom<dynamic, Track>(
|
||||||
|
result["tracks"].map((track) => Track.fromJson(track)).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import 'package:fl_query/fl_query.dart';
|
import 'package:fl_query/fl_query.dart';
|
||||||
|
import 'package:fl_query_hooks/fl_query_hooks.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/hooks/use_spotify_infinite_query.dart';
|
import 'package:spotube/hooks/use_spotify_infinite_query.dart';
|
||||||
|
import 'package:spotube/provider/custom_spotify_endpoint_provider.dart';
|
||||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||||
|
|
||||||
class CategoryQueries {
|
class CategoryQueries {
|
||||||
@ -69,4 +71,25 @@ class CategoryQueries {
|
|||||||
ref: ref,
|
ref: ref,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Query<List<String>, dynamic> genreSeeds(WidgetRef ref) {
|
||||||
|
final customSpotify = ref.watch(customSpotifyEndpointProvider);
|
||||||
|
final query = useQuery<List<String>, dynamic>(
|
||||||
|
"genre-seeds",
|
||||||
|
customSpotify.listGenreSeeds,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
return ref.listenManual(
|
||||||
|
customSpotifyEndpointProvider,
|
||||||
|
(previous, next) {
|
||||||
|
if (previous != next) {
|
||||||
|
query.refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
).close;
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,53 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:catcher/catcher.dart';
|
import 'package:catcher/catcher.dart';
|
||||||
import 'package:fl_query/fl_query.dart';
|
import 'package:fl_query/fl_query.dart';
|
||||||
|
import 'package:fl_query_hooks/fl_query_hooks.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/extensions/map.dart';
|
import 'package:spotube/extensions/map.dart';
|
||||||
import 'package:spotube/extensions/track.dart';
|
import 'package:spotube/extensions/track.dart';
|
||||||
import 'package:spotube/hooks/use_spotify_infinite_query.dart';
|
import 'package:spotube/hooks/use_spotify_infinite_query.dart';
|
||||||
import 'package:spotube/hooks/use_spotify_query.dart';
|
import 'package:spotube/hooks/use_spotify_query.dart';
|
||||||
|
import 'package:spotube/provider/custom_spotify_endpoint_provider.dart';
|
||||||
|
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||||
|
|
||||||
|
typedef RecommendationParameters = ({
|
||||||
|
double acousticness,
|
||||||
|
double danceability,
|
||||||
|
double duration_ms,
|
||||||
|
double energy,
|
||||||
|
double instrumentalness,
|
||||||
|
double key,
|
||||||
|
double liveness,
|
||||||
|
double loudness,
|
||||||
|
double mode,
|
||||||
|
double popularity,
|
||||||
|
double speechiness,
|
||||||
|
double tempo,
|
||||||
|
double time_signature,
|
||||||
|
double valence,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, num> recommendationParametersToMap(
|
||||||
|
RecommendationParameters params) =>
|
||||||
|
{
|
||||||
|
"acousticness": params.acousticness,
|
||||||
|
"danceability": params.danceability,
|
||||||
|
"duration_ms": params.duration_ms,
|
||||||
|
"energy": params.energy,
|
||||||
|
"instrumentalness": params.instrumentalness,
|
||||||
|
"key": params.key,
|
||||||
|
"liveness": params.liveness,
|
||||||
|
"loudness": params.loudness,
|
||||||
|
"mode": params.mode,
|
||||||
|
"popularity": params.popularity,
|
||||||
|
"speechiness": params.speechiness,
|
||||||
|
"tempo": params.tempo,
|
||||||
|
"time_signature": params.time_signature,
|
||||||
|
"valence": params.valence,
|
||||||
|
};
|
||||||
|
|
||||||
class PlaylistQueries {
|
class PlaylistQueries {
|
||||||
const PlaylistQueries();
|
const PlaylistQueries();
|
||||||
@ -94,4 +136,49 @@ class PlaylistQueries {
|
|||||||
ref: ref,
|
ref: ref,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Query<List<Track>, dynamic> generate(
|
||||||
|
WidgetRef ref, {
|
||||||
|
({List<String> tracks, List<String> artists, List<String> genres})? seeds,
|
||||||
|
RecommendationParameters? min,
|
||||||
|
RecommendationParameters? max,
|
||||||
|
RecommendationParameters? target,
|
||||||
|
int limit = 20,
|
||||||
|
String? market,
|
||||||
|
}) {
|
||||||
|
final marketOfPreference = ref.watch(
|
||||||
|
userPreferencesProvider.select((s) => s.recommendationMarket),
|
||||||
|
);
|
||||||
|
final customSpotify = ref.watch(customSpotifyEndpointProvider);
|
||||||
|
|
||||||
|
final query = useQuery<List<Track>, dynamic>(
|
||||||
|
"generate-playlist",
|
||||||
|
() async {
|
||||||
|
final tracks = await customSpotify.getRecommendations(
|
||||||
|
limit: limit,
|
||||||
|
market: market ?? marketOfPreference,
|
||||||
|
max: max != null ? recommendationParametersToMap(max) : null,
|
||||||
|
min: min != null ? recommendationParametersToMap(min) : null,
|
||||||
|
target: target != null ? recommendationParametersToMap(target) : null,
|
||||||
|
seedArtists: seeds?.artists,
|
||||||
|
seedGenres: seeds?.genres,
|
||||||
|
seedTracks: seeds?.tracks,
|
||||||
|
);
|
||||||
|
return tracks;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
return ref.listenManual(
|
||||||
|
customSpotifyEndpointProvider,
|
||||||
|
(previous, next) {
|
||||||
|
if (previous != next) {
|
||||||
|
query.refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
).close;
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -30,7 +30,7 @@ class CustomToastHandler extends ReportHandler {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
dismissable: true,
|
dismissable: true,
|
||||||
toastDuration: const Duration(seconds: 10),
|
toastDuration: const Duration(seconds: 5),
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
).show(context);
|
).show(context);
|
||||||
return true;
|
return true;
|
||||||
|
Loading…
Reference in New Issue
Block a user