feat: use providers in generate playlist

This commit is contained in:
Kingkor Roy Tirtho 2024-03-17 13:52:17 +06:00
parent b76e265f23
commit ac8121647b
8 changed files with 777 additions and 343 deletions

View File

@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart' hide Search; import 'package:spotify/spotify.dart' hide Search;
import 'package:spotube/models/spotify/recommendation_seeds.dart';
import 'package:spotube/pages/album/album.dart'; import 'package:spotube/pages/album/album.dart';
import 'package:spotube/pages/getting_started/getting_started.dart'; import 'package:spotube/pages/getting_started/getting_started.dart';
import 'package:spotube/pages/home/genres/genre_playlists.dart'; import 'package:spotube/pages/home/genres/genre_playlists.dart';
@ -96,8 +97,7 @@ final routerProvider = Provider((ref) {
path: "result", path: "result",
pageBuilder: (context, state) => SpotubePage( pageBuilder: (context, state) => SpotubePage(
child: PlaylistGenerateResultPage( child: PlaylistGenerateResultPage(
state: state: state.extra as GeneratePlaylistProviderInput,
state.extra as PlaylistGenerateResultRouteState,
), ),
), ),
), ),

View File

@ -3,9 +3,22 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'recommendation_seeds.freezed.dart'; part 'recommendation_seeds.freezed.dart';
part 'recommendation_seeds.g.dart'; part 'recommendation_seeds.g.dart';
@freezed
class GeneratePlaylistProviderInput with _$GeneratePlaylistProviderInput {
factory GeneratePlaylistProviderInput({
Iterable<String>? seedArtists,
Iterable<String>? seedGenres,
Iterable<String>? seedTracks,
required int limit,
RecommendationSeeds? max,
RecommendationSeeds? min,
RecommendationSeeds? target,
}) = _GeneratePlaylistProviderInput;
}
@freezed @freezed
class RecommendationSeeds with _$RecommendationSeeds { class RecommendationSeeds with _$RecommendationSeeds {
factory RecommendationSeeds( factory RecommendationSeeds({
num? acousticness, num? acousticness,
num? danceability, num? danceability,
@JsonKey(name: "duration_ms") num? durationMs, @JsonKey(name: "duration_ms") num? durationMs,
@ -20,7 +33,7 @@ class RecommendationSeeds with _$RecommendationSeeds {
num? tempo, num? tempo,
@JsonKey(name: "time_signature") num? timeSignature, @JsonKey(name: "time_signature") num? timeSignature,
num? valence, num? valence,
) = _RecommendationSeeds; }) = _RecommendationSeeds;
factory RecommendationSeeds.fromJson(Map<String, dynamic> json) => factory RecommendationSeeds.fromJson(Map<String, dynamic> json) =>
_$RecommendationSeedsFromJson(json); _$RecommendationSeedsFromJson(json);

View File

@ -14,6 +14,316 @@ T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError( final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
/// @nodoc
mixin _$GeneratePlaylistProviderInput {
Iterable<String>? get seedArtists => throw _privateConstructorUsedError;
Iterable<String>? get seedGenres => throw _privateConstructorUsedError;
Iterable<String>? get seedTracks => throw _privateConstructorUsedError;
int get limit => throw _privateConstructorUsedError;
RecommendationSeeds? get max => throw _privateConstructorUsedError;
RecommendationSeeds? get min => throw _privateConstructorUsedError;
RecommendationSeeds? get target => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$GeneratePlaylistProviderInputCopyWith<GeneratePlaylistProviderInput>
get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $GeneratePlaylistProviderInputCopyWith<$Res> {
factory $GeneratePlaylistProviderInputCopyWith(
GeneratePlaylistProviderInput value,
$Res Function(GeneratePlaylistProviderInput) then) =
_$GeneratePlaylistProviderInputCopyWithImpl<$Res,
GeneratePlaylistProviderInput>;
@useResult
$Res call(
{Iterable<String>? seedArtists,
Iterable<String>? seedGenres,
Iterable<String>? seedTracks,
int limit,
RecommendationSeeds? max,
RecommendationSeeds? min,
RecommendationSeeds? target});
$RecommendationSeedsCopyWith<$Res>? get max;
$RecommendationSeedsCopyWith<$Res>? get min;
$RecommendationSeedsCopyWith<$Res>? get target;
}
/// @nodoc
class _$GeneratePlaylistProviderInputCopyWithImpl<$Res,
$Val extends GeneratePlaylistProviderInput>
implements $GeneratePlaylistProviderInputCopyWith<$Res> {
_$GeneratePlaylistProviderInputCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? seedArtists = freezed,
Object? seedGenres = freezed,
Object? seedTracks = freezed,
Object? limit = null,
Object? max = freezed,
Object? min = freezed,
Object? target = freezed,
}) {
return _then(_value.copyWith(
seedArtists: freezed == seedArtists
? _value.seedArtists
: seedArtists // ignore: cast_nullable_to_non_nullable
as Iterable<String>?,
seedGenres: freezed == seedGenres
? _value.seedGenres
: seedGenres // ignore: cast_nullable_to_non_nullable
as Iterable<String>?,
seedTracks: freezed == seedTracks
? _value.seedTracks
: seedTracks // ignore: cast_nullable_to_non_nullable
as Iterable<String>?,
limit: null == limit
? _value.limit
: limit // ignore: cast_nullable_to_non_nullable
as int,
max: freezed == max
? _value.max
: max // ignore: cast_nullable_to_non_nullable
as RecommendationSeeds?,
min: freezed == min
? _value.min
: min // ignore: cast_nullable_to_non_nullable
as RecommendationSeeds?,
target: freezed == target
? _value.target
: target // ignore: cast_nullable_to_non_nullable
as RecommendationSeeds?,
) as $Val);
}
@override
@pragma('vm:prefer-inline')
$RecommendationSeedsCopyWith<$Res>? get max {
if (_value.max == null) {
return null;
}
return $RecommendationSeedsCopyWith<$Res>(_value.max!, (value) {
return _then(_value.copyWith(max: value) as $Val);
});
}
@override
@pragma('vm:prefer-inline')
$RecommendationSeedsCopyWith<$Res>? get min {
if (_value.min == null) {
return null;
}
return $RecommendationSeedsCopyWith<$Res>(_value.min!, (value) {
return _then(_value.copyWith(min: value) as $Val);
});
}
@override
@pragma('vm:prefer-inline')
$RecommendationSeedsCopyWith<$Res>? get target {
if (_value.target == null) {
return null;
}
return $RecommendationSeedsCopyWith<$Res>(_value.target!, (value) {
return _then(_value.copyWith(target: value) as $Val);
});
}
}
/// @nodoc
abstract class _$$GeneratePlaylistProviderInputImplCopyWith<$Res>
implements $GeneratePlaylistProviderInputCopyWith<$Res> {
factory _$$GeneratePlaylistProviderInputImplCopyWith(
_$GeneratePlaylistProviderInputImpl value,
$Res Function(_$GeneratePlaylistProviderInputImpl) then) =
__$$GeneratePlaylistProviderInputImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{Iterable<String>? seedArtists,
Iterable<String>? seedGenres,
Iterable<String>? seedTracks,
int limit,
RecommendationSeeds? max,
RecommendationSeeds? min,
RecommendationSeeds? target});
@override
$RecommendationSeedsCopyWith<$Res>? get max;
@override
$RecommendationSeedsCopyWith<$Res>? get min;
@override
$RecommendationSeedsCopyWith<$Res>? get target;
}
/// @nodoc
class __$$GeneratePlaylistProviderInputImplCopyWithImpl<$Res>
extends _$GeneratePlaylistProviderInputCopyWithImpl<$Res,
_$GeneratePlaylistProviderInputImpl>
implements _$$GeneratePlaylistProviderInputImplCopyWith<$Res> {
__$$GeneratePlaylistProviderInputImplCopyWithImpl(
_$GeneratePlaylistProviderInputImpl _value,
$Res Function(_$GeneratePlaylistProviderInputImpl) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? seedArtists = freezed,
Object? seedGenres = freezed,
Object? seedTracks = freezed,
Object? limit = null,
Object? max = freezed,
Object? min = freezed,
Object? target = freezed,
}) {
return _then(_$GeneratePlaylistProviderInputImpl(
seedArtists: freezed == seedArtists
? _value.seedArtists
: seedArtists // ignore: cast_nullable_to_non_nullable
as Iterable<String>?,
seedGenres: freezed == seedGenres
? _value.seedGenres
: seedGenres // ignore: cast_nullable_to_non_nullable
as Iterable<String>?,
seedTracks: freezed == seedTracks
? _value.seedTracks
: seedTracks // ignore: cast_nullable_to_non_nullable
as Iterable<String>?,
limit: null == limit
? _value.limit
: limit // ignore: cast_nullable_to_non_nullable
as int,
max: freezed == max
? _value.max
: max // ignore: cast_nullable_to_non_nullable
as RecommendationSeeds?,
min: freezed == min
? _value.min
: min // ignore: cast_nullable_to_non_nullable
as RecommendationSeeds?,
target: freezed == target
? _value.target
: target // ignore: cast_nullable_to_non_nullable
as RecommendationSeeds?,
));
}
}
/// @nodoc
class _$GeneratePlaylistProviderInputImpl
implements _GeneratePlaylistProviderInput {
_$GeneratePlaylistProviderInputImpl(
{this.seedArtists,
this.seedGenres,
this.seedTracks,
required this.limit,
this.max,
this.min,
this.target});
@override
final Iterable<String>? seedArtists;
@override
final Iterable<String>? seedGenres;
@override
final Iterable<String>? seedTracks;
@override
final int limit;
@override
final RecommendationSeeds? max;
@override
final RecommendationSeeds? min;
@override
final RecommendationSeeds? target;
@override
String toString() {
return 'GeneratePlaylistProviderInput(seedArtists: $seedArtists, seedGenres: $seedGenres, seedTracks: $seedTracks, limit: $limit, max: $max, min: $min, target: $target)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$GeneratePlaylistProviderInputImpl &&
const DeepCollectionEquality()
.equals(other.seedArtists, seedArtists) &&
const DeepCollectionEquality()
.equals(other.seedGenres, seedGenres) &&
const DeepCollectionEquality()
.equals(other.seedTracks, seedTracks) &&
(identical(other.limit, limit) || other.limit == limit) &&
(identical(other.max, max) || other.max == max) &&
(identical(other.min, min) || other.min == min) &&
(identical(other.target, target) || other.target == target));
}
@override
int get hashCode => Object.hash(
runtimeType,
const DeepCollectionEquality().hash(seedArtists),
const DeepCollectionEquality().hash(seedGenres),
const DeepCollectionEquality().hash(seedTracks),
limit,
max,
min,
target);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$GeneratePlaylistProviderInputImplCopyWith<
_$GeneratePlaylistProviderInputImpl>
get copyWith => __$$GeneratePlaylistProviderInputImplCopyWithImpl<
_$GeneratePlaylistProviderInputImpl>(this, _$identity);
}
abstract class _GeneratePlaylistProviderInput
implements GeneratePlaylistProviderInput {
factory _GeneratePlaylistProviderInput(
{final Iterable<String>? seedArtists,
final Iterable<String>? seedGenres,
final Iterable<String>? seedTracks,
required final int limit,
final RecommendationSeeds? max,
final RecommendationSeeds? min,
final RecommendationSeeds? target}) = _$GeneratePlaylistProviderInputImpl;
@override
Iterable<String>? get seedArtists;
@override
Iterable<String>? get seedGenres;
@override
Iterable<String>? get seedTracks;
@override
int get limit;
@override
RecommendationSeeds? get max;
@override
RecommendationSeeds? get min;
@override
RecommendationSeeds? get target;
@override
@JsonKey(ignore: true)
_$$GeneratePlaylistProviderInputImplCopyWith<
_$GeneratePlaylistProviderInputImpl>
get copyWith => throw _privateConstructorUsedError;
}
RecommendationSeeds _$RecommendationSeedsFromJson(Map<String, dynamic> json) { RecommendationSeeds _$RecommendationSeedsFromJson(Map<String, dynamic> json) {
return _RecommendationSeeds.fromJson(json); return _RecommendationSeeds.fromJson(json);
} }
@ -207,59 +517,59 @@ class __$$RecommendationSeedsImplCopyWithImpl<$Res>
Object? valence = freezed, Object? valence = freezed,
}) { }) {
return _then(_$RecommendationSeedsImpl( return _then(_$RecommendationSeedsImpl(
freezed == acousticness acousticness: freezed == acousticness
? _value.acousticness ? _value.acousticness
: acousticness // ignore: cast_nullable_to_non_nullable : acousticness // ignore: cast_nullable_to_non_nullable
as num?, as num?,
freezed == danceability danceability: freezed == danceability
? _value.danceability ? _value.danceability
: danceability // ignore: cast_nullable_to_non_nullable : danceability // ignore: cast_nullable_to_non_nullable
as num?, as num?,
freezed == durationMs durationMs: freezed == durationMs
? _value.durationMs ? _value.durationMs
: durationMs // ignore: cast_nullable_to_non_nullable : durationMs // ignore: cast_nullable_to_non_nullable
as num?, as num?,
freezed == energy energy: freezed == energy
? _value.energy ? _value.energy
: energy // ignore: cast_nullable_to_non_nullable : energy // ignore: cast_nullable_to_non_nullable
as num?, as num?,
freezed == instrumentalness instrumentalness: freezed == instrumentalness
? _value.instrumentalness ? _value.instrumentalness
: instrumentalness // ignore: cast_nullable_to_non_nullable : instrumentalness // ignore: cast_nullable_to_non_nullable
as num?, as num?,
freezed == key key: freezed == key
? _value.key ? _value.key
: key // ignore: cast_nullable_to_non_nullable : key // ignore: cast_nullable_to_non_nullable
as num?, as num?,
freezed == liveness liveness: freezed == liveness
? _value.liveness ? _value.liveness
: liveness // ignore: cast_nullable_to_non_nullable : liveness // ignore: cast_nullable_to_non_nullable
as num?, as num?,
freezed == loudness loudness: freezed == loudness
? _value.loudness ? _value.loudness
: loudness // ignore: cast_nullable_to_non_nullable : loudness // ignore: cast_nullable_to_non_nullable
as num?, as num?,
freezed == mode mode: freezed == mode
? _value.mode ? _value.mode
: mode // ignore: cast_nullable_to_non_nullable : mode // ignore: cast_nullable_to_non_nullable
as num?, as num?,
freezed == popularity popularity: freezed == popularity
? _value.popularity ? _value.popularity
: popularity // ignore: cast_nullable_to_non_nullable : popularity // ignore: cast_nullable_to_non_nullable
as num?, as num?,
freezed == speechiness speechiness: freezed == speechiness
? _value.speechiness ? _value.speechiness
: speechiness // ignore: cast_nullable_to_non_nullable : speechiness // ignore: cast_nullable_to_non_nullable
as num?, as num?,
freezed == tempo tempo: freezed == tempo
? _value.tempo ? _value.tempo
: tempo // ignore: cast_nullable_to_non_nullable : tempo // ignore: cast_nullable_to_non_nullable
as num?, as num?,
freezed == timeSignature timeSignature: freezed == timeSignature
? _value.timeSignature ? _value.timeSignature
: timeSignature // ignore: cast_nullable_to_non_nullable : timeSignature // ignore: cast_nullable_to_non_nullable
as num?, as num?,
freezed == valence valence: freezed == valence
? _value.valence ? _value.valence
: valence // ignore: cast_nullable_to_non_nullable : valence // ignore: cast_nullable_to_non_nullable
as num?, as num?,
@ -271,7 +581,7 @@ class __$$RecommendationSeedsImplCopyWithImpl<$Res>
@JsonSerializable() @JsonSerializable()
class _$RecommendationSeedsImpl implements _RecommendationSeeds { class _$RecommendationSeedsImpl implements _RecommendationSeeds {
_$RecommendationSeedsImpl( _$RecommendationSeedsImpl(
this.acousticness, {this.acousticness,
this.danceability, this.danceability,
@JsonKey(name: "duration_ms") this.durationMs, @JsonKey(name: "duration_ms") this.durationMs,
this.energy, this.energy,
@ -284,7 +594,7 @@ class _$RecommendationSeedsImpl implements _RecommendationSeeds {
this.speechiness, this.speechiness,
this.tempo, this.tempo,
@JsonKey(name: "time_signature") this.timeSignature, @JsonKey(name: "time_signature") this.timeSignature,
this.valence); this.valence});
factory _$RecommendationSeedsImpl.fromJson(Map<String, dynamic> json) => factory _$RecommendationSeedsImpl.fromJson(Map<String, dynamic> json) =>
_$$RecommendationSeedsImplFromJson(json); _$$RecommendationSeedsImplFromJson(json);
@ -391,7 +701,7 @@ class _$RecommendationSeedsImpl implements _RecommendationSeeds {
abstract class _RecommendationSeeds implements RecommendationSeeds { abstract class _RecommendationSeeds implements RecommendationSeeds {
factory _RecommendationSeeds( factory _RecommendationSeeds(
final num? acousticness, {final num? acousticness,
final num? danceability, final num? danceability,
@JsonKey(name: "duration_ms") final num? durationMs, @JsonKey(name: "duration_ms") final num? durationMs,
final num? energy, final num? energy,
@ -404,7 +714,7 @@ abstract class _RecommendationSeeds implements RecommendationSeeds {
final num? speechiness, final num? speechiness,
final num? tempo, final num? tempo,
@JsonKey(name: "time_signature") final num? timeSignature, @JsonKey(name: "time_signature") final num? timeSignature,
final num? valence) = _$RecommendationSeedsImpl; final num? valence}) = _$RecommendationSeedsImpl;
factory _RecommendationSeeds.fromJson(Map<String, dynamic> json) = factory _RecommendationSeeds.fromJson(Map<String, dynamic> json) =
_$RecommendationSeedsImpl.fromJson; _$RecommendationSeedsImpl.fromJson;

View File

@ -9,20 +9,20 @@ part of 'recommendation_seeds.dart';
_$RecommendationSeedsImpl _$$RecommendationSeedsImplFromJson( _$RecommendationSeedsImpl _$$RecommendationSeedsImplFromJson(
Map<String, dynamic> json) => Map<String, dynamic> json) =>
_$RecommendationSeedsImpl( _$RecommendationSeedsImpl(
json['acousticness'] as num?, acousticness: json['acousticness'] as num?,
json['danceability'] as num?, danceability: json['danceability'] as num?,
json['duration_ms'] as num?, durationMs: json['duration_ms'] as num?,
json['energy'] as num?, energy: json['energy'] as num?,
json['instrumentalness'] as num?, instrumentalness: json['instrumentalness'] as num?,
json['key'] as num?, key: json['key'] as num?,
json['liveness'] as num?, liveness: json['liveness'] as num?,
json['loudness'] as num?, loudness: json['loudness'] as num?,
json['mode'] as num?, mode: json['mode'] as num?,
json['popularity'] as num?, popularity: json['popularity'] as num?,
json['speechiness'] as num?, speechiness: json['speechiness'] as num?,
json['tempo'] as num?, tempo: json['tempo'] as num?,
json['time_signature'] as num?, timeSignature: json['time_signature'] as num?,
json['valence'] as num?, valence: json['valence'] as num?,
); );
Map<String, dynamic> _$$RecommendationSeedsImplToJson( Map<String, dynamic> _$$RecommendationSeedsImplToJson(

View File

@ -15,11 +15,10 @@ import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart'; import 'package:spotube/models/spotify/recommendation_seeds.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/queries/playlist.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
const RecommendationAttribute zeroValues = (min: 0, target: 0, max: 0); const RecommendationAttribute zeroValues = (min: 0, target: 0, max: 0);
@ -35,7 +34,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
final textTheme = theme.textTheme; final textTheme = theme.textTheme;
final preferences = ref.watch(userPreferencesProvider); final preferences = ref.watch(userPreferencesProvider);
final genresCollection = useQueries.category.genreSeeds(ref); final genresCollection = ref.watch(categoryGenresProvider);
final limit = useValueNotifier<int>(10); final limit = useValueNotifier<int>(10);
final market = useValueNotifier<Market>(preferences.recommendationMarket); final market = useValueNotifier<Market>(preferences.recommendationMarket);
@ -51,22 +50,9 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
5 - genres.value.length - artists.value.length - tracks.value.length; 5 - genres.value.length - artists.value.length - tracks.value.length;
// Dial (int 0-1) attributes // Dial (int 0-1) attributes
final acousticness = useState<RecommendationAttribute>(zeroValues); final min = useState<RecommendationSeeds>(RecommendationSeeds());
final danceability = useState<RecommendationAttribute>(zeroValues); final max = useState<RecommendationSeeds>(RecommendationSeeds());
final energy = useState<RecommendationAttribute>(zeroValues); final target = useState<RecommendationSeeds>(RecommendationSeeds());
final instrumentalness = useState<RecommendationAttribute>(zeroValues);
final key = useState<RecommendationAttribute>(zeroValues);
final liveness = useState<RecommendationAttribute>(zeroValues);
final loudness = useState<RecommendationAttribute>(zeroValues);
final popularity = useState<RecommendationAttribute>(zeroValues);
final speechiness = useState<RecommendationAttribute>(zeroValues);
final valence = useState<RecommendationAttribute>(zeroValues);
// Field editable attributes
final tempo = useState<RecommendationAttribute>(zeroValues);
final durationMs = useState<RecommendationAttribute>(zeroValues);
final mode = useState<RecommendationAttribute>(zeroValues);
final timeSignature = useState<RecommendationAttribute>(zeroValues);
final artistAutoComplete = SeedsMultiAutocomplete<Artist>( final artistAutoComplete = SeedsMultiAutocomplete<Artist>(
seeds: artists, seeds: artists,
@ -204,7 +190,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
); );
final genreSelector = MultiSelectField<String>( final genreSelector = MultiSelectField<String>(
options: genresCollection.data ?? [], options: genresCollection.value ?? [],
selectedOptions: genres.value, selectedOptions: genres.value,
getValueForOption: (option) => option, getValueForOption: (option) => option,
onSelected: (value) { onSelected: (value) {
@ -356,88 +342,213 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
RecommendationAttributeDials( RecommendationAttributeDials(
title: Text(context.l10n.acousticness), title: Text(context.l10n.acousticness),
values: acousticness.value, values: (
target: target.value.acousticness?.toDouble() ?? 0,
min: min.value.acousticness?.toDouble() ?? 0,
max: max.value.acousticness?.toDouble() ?? 0,
),
onChanged: (value) { onChanged: (value) {
acousticness.value = value; target.value = target.value.copyWith(
acousticness: value.target,
);
min.value = min.value.copyWith(
acousticness: value.min,
);
max.value = max.value.copyWith(
acousticness: value.max,
);
}, },
), ),
RecommendationAttributeDials( RecommendationAttributeDials(
title: Text(context.l10n.danceability), title: Text(context.l10n.danceability),
values: danceability.value, values: (
target: target.value.danceability?.toDouble() ?? 0,
min: min.value.danceability?.toDouble() ?? 0,
max: max.value.danceability?.toDouble() ?? 0,
),
onChanged: (value) { onChanged: (value) {
danceability.value = value; target.value = target.value.copyWith(
danceability: value.target,
);
min.value = min.value.copyWith(
danceability: value.min,
);
max.value = max.value.copyWith(
danceability: value.max,
);
}, },
), ),
RecommendationAttributeDials( RecommendationAttributeDials(
title: Text(context.l10n.energy), title: Text(context.l10n.energy),
values: energy.value, values: (
target: target.value.energy?.toDouble() ?? 0,
min: min.value.energy?.toDouble() ?? 0,
max: max.value.energy?.toDouble() ?? 0,
),
onChanged: (value) { onChanged: (value) {
energy.value = value; target.value = target.value.copyWith(
energy: value.target,
);
min.value = min.value.copyWith(
energy: value.min,
);
max.value = max.value.copyWith(
energy: value.max,
);
}, },
), ),
RecommendationAttributeDials( RecommendationAttributeDials(
title: Text(context.l10n.instrumentalness), title: Text(context.l10n.instrumentalness),
values: instrumentalness.value, values: (
target:
target.value.instrumentalness?.toDouble() ?? 0,
min: min.value.instrumentalness?.toDouble() ?? 0,
max: max.value.instrumentalness?.toDouble() ?? 0,
),
onChanged: (value) { onChanged: (value) {
instrumentalness.value = value; target.value = target.value.copyWith(
instrumentalness: value.target,
);
min.value = min.value.copyWith(
instrumentalness: value.min,
);
max.value = max.value.copyWith(
instrumentalness: value.max,
);
}, },
), ),
RecommendationAttributeDials( RecommendationAttributeDials(
title: Text(context.l10n.liveness), title: Text(context.l10n.liveness),
values: liveness.value, values: (
target: target.value.liveness?.toDouble() ?? 0,
min: min.value.liveness?.toDouble() ?? 0,
max: max.value.liveness?.toDouble() ?? 0,
),
onChanged: (value) { onChanged: (value) {
liveness.value = value; target.value = target.value.copyWith(
liveness: value.target,
);
min.value = min.value.copyWith(
liveness: value.min,
);
max.value = max.value.copyWith(
liveness: value.max,
);
}, },
), ),
RecommendationAttributeDials( RecommendationAttributeDials(
title: Text(context.l10n.loudness), title: Text(context.l10n.loudness),
values: loudness.value, values: (
target: target.value.loudness?.toDouble() ?? 0,
min: min.value.loudness?.toDouble() ?? 0,
max: max.value.loudness?.toDouble() ?? 0,
),
onChanged: (value) { onChanged: (value) {
loudness.value = value; target.value = target.value.copyWith(
loudness: value.target,
);
min.value = min.value.copyWith(
loudness: value.min,
);
max.value = max.value.copyWith(
loudness: value.max,
);
}, },
), ),
RecommendationAttributeDials( RecommendationAttributeDials(
title: Text(context.l10n.speechiness), title: Text(context.l10n.speechiness),
values: speechiness.value, values: (
target: target.value.speechiness?.toDouble() ?? 0,
min: min.value.speechiness?.toDouble() ?? 0,
max: max.value.speechiness?.toDouble() ?? 0,
),
onChanged: (value) { onChanged: (value) {
speechiness.value = value; target.value = target.value.copyWith(
speechiness: value.target,
);
min.value = min.value.copyWith(
speechiness: value.min,
);
max.value = max.value.copyWith(
speechiness: value.max,
);
}, },
), ),
RecommendationAttributeDials( RecommendationAttributeDials(
title: Text(context.l10n.valence), title: Text(context.l10n.valence),
values: valence.value, values: (
target: target.value.valence?.toDouble() ?? 0,
min: min.value.valence?.toDouble() ?? 0,
max: max.value.valence?.toDouble() ?? 0,
),
onChanged: (value) { onChanged: (value) {
valence.value = value; target.value = target.value.copyWith(
valence: value.target,
);
min.value = min.value.copyWith(
valence: value.min,
);
max.value = max.value.copyWith(
valence: value.max,
);
}, },
), ),
RecommendationAttributeDials( RecommendationAttributeDials(
title: Text(context.l10n.popularity), title: Text(context.l10n.popularity),
values: popularity.value,
base: 100, base: 100,
values: (
target: target.value.popularity?.toDouble() ?? 0,
min: min.value.popularity?.toDouble() ?? 0,
max: max.value.popularity?.toDouble() ?? 0,
),
onChanged: (value) { onChanged: (value) {
popularity.value = value; target.value = target.value.copyWith(
popularity: value.target,
);
min.value = min.value.copyWith(
popularity: value.min,
);
max.value = max.value.copyWith(
popularity: value.max,
);
}, },
), ),
RecommendationAttributeDials( RecommendationAttributeDials(
title: Text(context.l10n.key), title: Text(context.l10n.key),
values: key.value,
base: 11, base: 11,
values: (
target: target.value.key?.toDouble() ?? 0,
min: min.value.key?.toDouble() ?? 0,
max: max.value.key?.toDouble() ?? 0,
),
onChanged: (value) { onChanged: (value) {
key.value = value; target.value = target.value.copyWith(
key: value.target,
);
min.value = min.value.copyWith(
key: value.min,
);
max.value = max.value.copyWith(
key: value.max,
);
}, },
), ),
RecommendationAttributeFields( RecommendationAttributeFields(
title: Text(context.l10n.duration), title: Text(context.l10n.duration),
values: ( values: (
max: durationMs.value.max / 1000, max: (max.value.durationMs ?? 0) / 1000,
target: durationMs.value.target / 1000, target: (target.value.durationMs ?? 0) / 1000,
min: durationMs.value.min / 1000, min: (min.value.durationMs ?? 0) / 1000,
), ),
onChanged: (value) { onChanged: (value) {
durationMs.value = ( target.value = target.value.copyWith(
max: value.max * 1000, durationMs: (value.target * 1000).toInt(),
target: value.target * 1000, );
min: value.min * 1000, min.value = min.value.copyWith(
durationMs: (value.min * 1000).toInt(),
);
max.value = max.value.copyWith(
durationMs: (value.max * 1000).toInt(),
); );
}, },
presets: { presets: {
@ -452,23 +563,59 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
), ),
RecommendationAttributeFields( RecommendationAttributeFields(
title: Text(context.l10n.tempo), title: Text(context.l10n.tempo),
values: tempo.value, values: (
max: max.value.tempo?.toDouble() ?? 0,
target: target.value.tempo?.toDouble() ?? 0,
min: min.value.tempo?.toDouble() ?? 0,
),
onChanged: (value) { onChanged: (value) {
tempo.value = value; target.value = target.value.copyWith(
tempo: value.target,
);
min.value = min.value.copyWith(
tempo: value.min,
);
max.value = max.value.copyWith(
tempo: value.max,
);
}, },
), ),
RecommendationAttributeFields( RecommendationAttributeFields(
title: Text(context.l10n.mode), title: Text(context.l10n.mode),
values: mode.value, values: (
max: max.value.mode?.toDouble() ?? 0,
target: target.value.mode?.toDouble() ?? 0,
min: min.value.mode?.toDouble() ?? 0,
),
onChanged: (value) { onChanged: (value) {
mode.value = value; target.value = target.value.copyWith(
mode: value.target,
);
min.value = min.value.copyWith(
mode: value.min,
);
max.value = max.value.copyWith(
mode: value.max,
);
}, },
), ),
RecommendationAttributeFields( RecommendationAttributeFields(
title: Text(context.l10n.time_signature), title: Text(context.l10n.time_signature),
values: timeSignature.value, values: (
max: max.value.timeSignature?.toDouble() ?? 0,
target: target.value.timeSignature?.toDouble() ?? 0,
min: min.value.timeSignature?.toDouble() ?? 0,
),
onChanged: (value) { onChanged: (value) {
timeSignature.value = value; target.value = target.value.copyWith(
timeSignature: value.target,
);
min.value = min.value.copyWith(
timeSignature: value.min,
);
max.value = max.value.copyWith(
timeSignature: value.max,
);
}, },
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
@ -480,35 +627,18 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
genres.value.isEmpty genres.value.isEmpty
? null ? null
: () { : () {
final PlaylistGenerateResultRouteState final routeState =
routeState = ( GeneratePlaylistProviderInput(
seeds: ( seedArtists: artists.value
artists: artists.value .map((a) => a.id!)
.map((a) => a.id!) .toList(),
.toList(), seedTracks:
tracks: tracks.value tracks.value.map((t) => t.id!).toList(),
.map((t) => t.id!) seedGenres: genres.value,
.toList(),
genres: genres.value
),
market: market.value,
limit: limit.value, limit: limit.value,
parameters: ( max: max.value,
acousticness: acousticness.value, min: min.value,
danceability: danceability.value, target: target.value,
energy: energy.value,
instrumentalness: instrumentalness.value,
liveness: liveness.value,
loudness: loudness.value,
speechiness: speechiness.value,
valence: valence.value,
popularity: popularity.value,
key: key.value,
duration_ms: durationMs.value,
tempo: tempo.value,
mode: mode.value,
time_signature: timeSignature.value,
),
); );
GoRouter.of(context).push( GoRouter.of(context).push(
"/library/generate/result", "/library/generate/result",

View File

@ -1,4 +1,3 @@
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:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -10,19 +9,12 @@ import 'package:spotube/components/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/spotify/recommendation_seeds.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/queries/playlist.dart'; import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/services/queries/queries.dart';
typedef PlaylistGenerateResultRouteState = ({
({List<String> tracks, List<String> artists, List<String> genres})? seeds,
RecommendationParameters? parameters,
int limit,
Market? market,
});
class PlaylistGenerateResultPage extends HookConsumerWidget { class PlaylistGenerateResultPage extends HookConsumerWidget {
final PlaylistGenerateResultRouteState state; final GeneratePlaylistProviderInput state;
const PlaylistGenerateResultPage({ const PlaylistGenerateResultPage({
super.key, super.key,
@ -34,225 +26,207 @@ class PlaylistGenerateResultPage extends HookConsumerWidget {
final router = GoRouter.of(context); final router = GoRouter.of(context);
final scaffoldMessenger = ScaffoldMessenger.of(context); final scaffoldMessenger = ScaffoldMessenger.of(context);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final (:seeds, :parameters, :limit, :market) = state;
final queryClient = useQueryClient(); final generatedPlaylist = ref.watch(generatePlaylistProvider(state));
final generatedPlaylist = useQueries.playlist.generate(
ref,
seeds: seeds,
parameters: parameters,
limit: limit,
market: market,
);
final selectedTracks = useState<List<String>>( final selectedTracks = useState<List<String>>(
generatedPlaylist.data?.map((e) => e.id!).toList() ?? [], generatedPlaylist.value?.map((e) => e.id!).toList() ?? [],
); );
useEffect(() { useEffect(() {
if (generatedPlaylist.data != null) { if (generatedPlaylist.value != null) {
selectedTracks.value = selectedTracks.value =
generatedPlaylist.data!.map((e) => e.id!).toList(); generatedPlaylist.value!.map((e) => e.id!).toList();
} }
return null; return null;
}, [generatedPlaylist.data]); }, [generatedPlaylist.value]);
final isAllTrackSelected = final isAllTrackSelected =
selectedTracks.value.length == (generatedPlaylist.data?.length ?? 0); selectedTracks.value.length == (generatedPlaylist.value?.length ?? 0);
return WillPopScope( return Scaffold(
onWillPop: () async { appBar: const PageWindowTitleBar(leading: BackButton()),
queryClient.cache.removeQuery(generatedPlaylist); body: generatedPlaylist.isLoading
return true; ? Center(
}, child: Column(
child: Scaffold( mainAxisAlignment: MainAxisAlignment.center,
appBar: const PageWindowTitleBar(leading: BackButton()), crossAxisAlignment: CrossAxisAlignment.center,
body: generatedPlaylist.isLoading children: [
? Center( const CircularProgressIndicator(),
child: Column( Text(context.l10n.generating_playlist),
mainAxisAlignment: MainAxisAlignment.center, ],
crossAxisAlignment: CrossAxisAlignment.center, ),
children: [ )
const CircularProgressIndicator(), : Padding(
Text(context.l10n.generating_playlist), padding: const EdgeInsets.all(8.0),
], child: ListView(
), children: [
) GridView(
: Padding( gridDelegate:
padding: const EdgeInsets.all(8.0), const SliverGridDelegateWithFixedCrossAxisCount(
child: ListView( crossAxisCount: 2,
children: [ crossAxisSpacing: 8,
GridView( mainAxisSpacing: 8,
gridDelegate: mainAxisExtent: 32,
const SliverGridDelegateWithFixedCrossAxisCount( ),
crossAxisCount: 2, shrinkWrap: true,
crossAxisSpacing: 8, children: [
mainAxisSpacing: 8, FilledButton.tonalIcon(
mainAxisExtent: 32, icon: const Icon(SpotubeIcons.play),
label: Text(context.l10n.play),
onPressed: selectedTracks.value.isEmpty
? null
: () async {
await playlistNotifier.load(
generatedPlaylist.value!.where(
(e) => selectedTracks.value.contains(e.id!),
),
autoPlay: true,
);
},
), ),
shrinkWrap: 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.value!.where(
(e) => selectedTracks.value.contains(e.id!),
),
);
if (context.mounted) {
scaffoldMessenger.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
: () async {
final playlist = await showDialog<Playlist>(
context: context,
builder: (context) => PlaylistCreateDialog(
trackIds: selectedTracks.value,
),
);
if (playlist != null) {
router.go(
'/playlist/${playlist.id}',
extra: playlist,
);
}
},
),
FilledButton.tonalIcon(
icon: const Icon(SpotubeIcons.playlistAdd),
label: Text(context.l10n.add_to_playlist),
onPressed: selectedTracks.value.isEmpty
? null
: () async {
final hasAdded = await showDialog<bool>(
context: context,
builder: (context) => PlaylistAddTrackDialog(
openFromPlaylist: null,
tracks: selectedTracks.value
.map(
(e) => generatedPlaylist.value!
.firstWhere(
(element) => element.id == e,
),
)
.toList(),
),
);
if (context.mounted && hasAdded == true) {
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
context.l10n.add_count_to_playlist(
selectedTracks.value.length,
),
),
),
);
}
},
)
],
),
const SizedBox(height: 16),
if (generatedPlaylist.value != null)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
FilledButton.tonalIcon( Text(
icon: const Icon(SpotubeIcons.play), context.l10n.selected_count_tracks(
label: Text(context.l10n.play), selectedTracks.value.length,
onPressed: selectedTracks.value.isEmpty ),
? null
: () async {
await playlistNotifier.load(
generatedPlaylist.data!.where(
(e) =>
selectedTracks.value.contains(e.id!),
),
autoPlay: true,
);
},
), ),
FilledButton.tonalIcon( ElevatedButton.icon(
icon: const Icon(SpotubeIcons.queueAdd), onPressed: () {
label: Text(context.l10n.add_to_queue), if (isAllTrackSelected) {
onPressed: selectedTracks.value.isEmpty selectedTracks.value = [];
? null } else {
: () async { selectedTracks.value = generatedPlaylist.value
await playlistNotifier.addTracks( ?.map((e) => e.id!)
generatedPlaylist.data!.where( .toList() ??
(e) => [];
selectedTracks.value.contains(e.id!), }
), },
); icon: const Icon(SpotubeIcons.selectionCheck),
if (context.mounted) { label: Text(
scaffoldMessenger.showSnackBar( isAllTrackSelected
SnackBar( ? context.l10n.deselect_all
content: Text( : context.l10n.select_all,
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
: () async {
final playlist = await showDialog<Playlist>(
context: context,
builder: (context) => PlaylistCreateDialog(
trackIds: selectedTracks.value,
),
);
if (playlist != null) {
router.go(
'/playlist/${playlist.id}',
extra: playlist,
);
}
},
),
FilledButton.tonalIcon(
icon: const Icon(SpotubeIcons.playlistAdd),
label: Text(context.l10n.add_to_playlist),
onPressed: selectedTracks.value.isEmpty
? null
: () async {
final hasAdded = await showDialog<bool>(
context: context,
builder: (context) =>
PlaylistAddTrackDialog(
openFromPlaylist: null,
tracks: selectedTracks.value
.map(
(e) => generatedPlaylist.data!
.firstWhere(
(element) => element.id == e,
),
)
.toList(),
),
);
if (context.mounted && hasAdded == true) {
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
context.l10n.add_count_to_playlist(
selectedTracks.value.length,
),
),
),
);
}
},
)
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 8),
if (generatedPlaylist.data != null) Card(
Row( margin: const EdgeInsets.all(0),
mainAxisAlignment: MainAxisAlignment.spaceBetween, child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( for (final track in generatedPlaylist.value ?? [])
context.l10n.selected_count_tracks( CheckboxListTile(
selectedTracks.value.length, value: selectedTracks.value.contains(track.id),
), onChanged: (value) {
), if (value == true) {
ElevatedButton.icon( selectedTracks.value.add(track.id!);
onPressed: () { } else {
if (isAllTrackSelected) { selectedTracks.value.remove(track.id);
selectedTracks.value = []; }
} else { selectedTracks.value =
selectedTracks.value = generatedPlaylist.data selectedTracks.value.toList();
?.map((e) => e.id!) },
.toList() ?? controlAffinity: ListTileControlAffinity.leading,
[]; contentPadding: EdgeInsets.zero,
} dense: true,
}, title: SimpleTrackTile(track: track),
icon: const Icon(SpotubeIcons.selectionCheck), )
label: Text(
isAllTrackSelected
? context.l10n.deselect_all
: context.l10n.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),
)
],
),
),
), ),
], ),
), ],
), ),
), ),
); );
} }
} }

View File

@ -1,34 +1,40 @@
part of '../spotify.dart'; part of '../spotify.dart';
typedef GeneratePlaylistProviderInput = ({ final generatePlaylistProvider = FutureProvider.autoDispose
Iterable<String>? seedArtists, .family<List<Track>, GeneratePlaylistProviderInput>(
Iterable<String>? seedGenres,
Iterable<String>? seedTracks,
int limit,
RecommendationSeeds? max,
RecommendationSeeds? min,
RecommendationSeeds? target,
});
final generatePlaylistProvider =
FutureProvider.family<List<TrackSimple>, GeneratePlaylistProviderInput>(
(ref, input) async { (ref, input) async {
final spotify = ref.watch(spotifyProvider); final spotify = ref.watch(spotifyProvider);
final market = ref.watch( final market = ref.watch(
userPreferencesProvider.select((s) => s.recommendationMarket), userPreferencesProvider.select((s) => s.recommendationMarket),
); );
final recommendation = await spotify.recommendations.get( final recommendation = await spotify.recommendations
.get(
limit: input.limit, limit: input.limit,
seedArtists: input.seedArtists?.toList(), seedArtists: input.seedArtists?.toList(),
seedGenres: input.seedGenres?.toList(), seedGenres: input.seedGenres?.toList(),
seedTracks: input.seedTracks?.toList(), seedTracks: input.seedTracks?.toList(),
market: market, market: market,
max: input.max?.toJson().cast<String, num>(), max: (input.max?.toJson()?..removeWhere((key, value) => value == null))
min: input.min?.toJson().cast<String, num>(), ?.cast<String, num>(),
target: input.target?.toJson().cast<String, num>(), min: (input.min?.toJson()?..removeWhere((key, value) => value == null))
); ?.cast<String, num>(),
target: (input.target?.toJson()
?..removeWhere((key, value) => value == null))
?.cast<String, num>(),
)
.catchError((e, stackTrace) {
Catcher2.reportCheckedError(e, stackTrace);
return Recommendations();
});
return recommendation.tracks?.toList() ?? []; if (recommendation.tracks?.isEmpty ?? true) {
return [];
}
final tracks = await spotify.tracks
.list(recommendation.tracks!.map((e) => e.id!).toList());
return tracks.toList();
}, },
); );

View File

@ -3,6 +3,7 @@ library spotify;
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:catcher_2/catcher_2.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';