fix(android): back button and safe area issues

This commit is contained in:
Kingkor Roy Tirtho 2025-01-31 23:07:37 +06:00
parent 6ddf6b9cce
commit d4504722d8
21 changed files with 1559 additions and 1516 deletions

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
@ -73,6 +74,10 @@ class TitleBar extends HookConsumerWidget implements PreferredSizeWidget {
final hasFullscreen =
MediaQuery.sizeOf(context).width == constraints.maxWidth;
final canPop = leading.isEmpty &&
automaticallyImplyLeading &&
(Navigator.canPop(context) || context.watchRouter.canPop());
return GestureDetector(
onHorizontalDragStart: (_) => onDrag(ref),
onVerticalDragStart: (_) => onDrag(ref),
@ -94,13 +99,7 @@ class TitleBar extends HookConsumerWidget implements PreferredSizeWidget {
}
},
child: AppBar(
leading: leading.isEmpty &&
automaticallyImplyLeading &&
Navigator.canPop(context)
? [
const BackButton(),
]
: leading,
leading: canPop ? [const BackButton()] : leading,
trailing: [
...trailing,
Align(

View File

@ -23,65 +23,65 @@ class ConnectPage extends HookConsumerWidget {
final connectClientsNotifier = ref.read(connectClientsProvider.notifier);
final discoveredDevices = connectClients.asData?.value.services;
return Scaffold(
headers: [
TitleBar(
automaticallyImplyLeading: true,
title: Text(context.l10n.devices),
)
],
child: Padding(
padding: const EdgeInsets.all(10.0),
child: CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
sliver: SliverToBoxAdapter(
child: Text(
context.l10n.remote,
style: typography.bold,
return SafeArea(
bottom: false,
child: Scaffold(
headers: [
TitleBar(title: Text(context.l10n.devices)),
],
child: Padding(
padding: const EdgeInsets.all(10.0),
child: CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
sliver: SliverToBoxAdapter(
child: Text(
context.l10n.remote,
style: typography.bold,
),
),
),
),
const SliverGap(10),
SliverList.separated(
itemCount: discoveredDevices?.length ?? 0,
separatorBuilder: (context, index) => const Gap(10),
itemBuilder: (context, index) {
final device = discoveredDevices![index];
final selected =
connectClients.asData?.value.resolvedService?.name ==
device.name;
return ButtonTile(
selected: selected,
leading: const Icon(SpotubeIcons.monitor),
title: Text(device.name),
subtitle: selected
? Text(
"${connectClients.asData?.value.resolvedService?.host}"
":${connectClients.asData?.value.resolvedService?.port}",
)
: null,
trailing: selected
? IconButton.outline(
icon: const Icon(SpotubeIcons.power),
size: ButtonSize.small,
onPressed: () =>
connectClientsNotifier.clearResolvedService(),
)
: null,
onPressed: () {
if (selected) {
context.navigateTo(const ConnectControlRoute());
} else {
connectClientsNotifier.resolveService(device);
}
},
);
},
),
const ConnectPageLocalDevices(),
],
const SliverGap(10),
SliverList.separated(
itemCount: discoveredDevices?.length ?? 0,
separatorBuilder: (context, index) => const Gap(10),
itemBuilder: (context, index) {
final device = discoveredDevices![index];
final selected =
connectClients.asData?.value.resolvedService?.name ==
device.name;
return ButtonTile(
selected: selected,
leading: const Icon(SpotubeIcons.monitor),
title: Text(device.name),
subtitle: selected
? Text(
"${connectClients.asData?.value.resolvedService?.host}"
":${connectClients.asData?.value.resolvedService?.port}",
)
: null,
trailing: selected
? IconButton.outline(
icon: const Icon(SpotubeIcons.power),
size: ButtonSize.small,
onPressed: () =>
connectClientsNotifier.clearResolvedService(),
)
: null,
onPressed: () {
if (selected) {
context.navigateTo(const ConnectControlRoute());
} else {
connectClientsNotifier.resolveService(device);
}
},
);
},
),
const ConnectPageLocalDevices(),
],
),
),
),
);

View File

@ -75,7 +75,6 @@ class ConnectControlPage extends HookConsumerWidget {
headers: [
TitleBar(
title: Text(resolvedService!.name),
automaticallyImplyLeading: true,
)
],
child: LayoutBuilder(builder: (context, constrains) {

View File

@ -28,68 +28,71 @@ class HomeFeedSectionPage extends HookConsumerWidget {
final controller = useScrollController();
final isArtist = section.items.every((item) => item.artist != null);
return Skeletonizer(
enabled: homeFeedSection.isLoading,
child: Scaffold(
headers: [
TitleBar(
title: Text(section.title ?? ""),
automaticallyImplyLeading: true,
)
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: CustomScrollView(
controller: controller,
slivers: [
if (isArtist)
SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
mainAxisExtent: 250,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
return SafeArea(
bottom: false,
child: Skeletonizer(
enabled: homeFeedSection.isLoading,
child: Scaffold(
headers: [
TitleBar(
title: Text(section.title ?? ""),
)
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: CustomScrollView(
controller: controller,
slivers: [
if (isArtist)
SliverGrid.builder(
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
mainAxisExtent: 250,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: section.items.length,
itemBuilder: (context, index) {
final item = section.items[index];
return ArtistCard(item.artist!.asArtist);
},
)
else
PlaybuttonView(
controller: controller,
itemCount: section.items.length,
hasMore: false,
isLoading: false,
onRequestMore: () => {},
listItemBuilder: (context, index) {
final item = section.items[index];
if (item.album != null) {
return AlbumCard.tile(item.album!.asAlbum);
}
if (item.playlist != null) {
return PlaylistCard.tile(item.playlist!.asPlaylist);
}
return const SizedBox.shrink();
},
gridItemBuilder: (context, index) {
final item = section.items[index];
if (item.album != null) {
return AlbumCard(item.album!.asAlbum);
}
if (item.playlist != null) {
return PlaylistCard(item.playlist!.asPlaylist);
}
return const SizedBox.shrink();
},
),
const SliverToBoxAdapter(
child: SafeArea(
child: SizedBox(),
),
itemCount: section.items.length,
itemBuilder: (context, index) {
final item = section.items[index];
return ArtistCard(item.artist!.asArtist);
},
)
else
PlaybuttonView(
controller: controller,
itemCount: section.items.length,
hasMore: false,
isLoading: false,
onRequestMore: () => {},
listItemBuilder: (context, index) {
final item = section.items[index];
if (item.album != null) {
return AlbumCard.tile(item.album!.asAlbum);
}
if (item.playlist != null) {
return PlaylistCard.tile(item.playlist!.asPlaylist);
}
return const SizedBox.shrink();
},
gridItemBuilder: (context, index) {
final item = section.items[index];
if (item.album != null) {
return AlbumCard(item.album!.asAlbum);
}
if (item.playlist != null) {
return PlaylistCard(item.playlist!.asPlaylist);
}
return const SizedBox.shrink();
},
),
const SliverToBoxAdapter(
child: SafeArea(
child: SizedBox(),
),
),
],
],
),
),
),
),

View File

@ -45,93 +45,98 @@ class GenrePlaylistsPage extends HookConsumerWidget {
automaticSystemUiAdjustment: false,
);
return Scaffold(
headers: [
if (kIsDesktop)
const TitleBar(
leading: [
BackButton(),
],
backgroundColor: Colors.transparent,
surfaceOpacity: 0,
surfaceBlur: 0,
)
],
floatingHeader: true,
child: DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: UniversalImage.imageProvider(category.icons!.first.url!),
alignment: Alignment.topCenter,
fit: BoxFit.cover,
repeat: ImageRepeat.noRepeat,
matchTextDirection: true,
return SafeArea(
child: Scaffold(
headers: [
if (kIsDesktop)
const TitleBar(
leading: [
BackButton(),
],
backgroundColor: Colors.transparent,
surfaceOpacity: 0,
surfaceBlur: 0,
)
],
floatingHeader: true,
child: DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: UniversalImage.imageProvider(category.icons!.first.url!),
alignment: Alignment.topCenter,
fit: BoxFit.cover,
repeat: ImageRepeat.noRepeat,
matchTextDirection: true,
),
),
),
child: SurfaceCard(
borderRadius: BorderRadius.zero,
padding: EdgeInsets.zero,
child: CustomScrollView(
controller: scrollController,
slivers: [
SliverAppBar(
automaticallyImplyLeading: false,
leading: kIsMobile ? const BackButton() : null,
expandedHeight: mediaQuery.mdAndDown ? 200 : 150,
title: const Text(""),
backgroundColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
centerTitle: kIsDesktop,
title: Text(
category.name!,
style: context.theme.typography.h3.copyWith(
color: Colors.white,
letterSpacing: 3,
shadows: [
Shadow(
offset: const Offset(-1.5, -1.5),
color: Colors.black.withAlpha(138),
child: SurfaceCard(
borderRadius: BorderRadius.zero,
padding: EdgeInsets.zero,
child: CustomScrollView(
controller: scrollController,
slivers: [
SliverSafeArea(
bottom: false,
sliver: SliverAppBar(
automaticallyImplyLeading: false,
leading: kIsMobile ? const BackButton() : null,
expandedHeight: mediaQuery.mdAndDown ? 200 : 150,
title: const Text(""),
backgroundColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
centerTitle: kIsDesktop,
title: Text(
category.name!,
style: context.theme.typography.h3.copyWith(
color: Colors.white,
letterSpacing: 3,
shadows: [
Shadow(
offset: const Offset(-1.5, -1.5),
color: Colors.black.withAlpha(138),
),
Shadow(
offset: const Offset(1.5, -1.5),
color: Colors.black.withAlpha(138),
),
Shadow(
offset: const Offset(1.5, 1.5),
color: Colors.black.withAlpha(138),
),
Shadow(
offset: const Offset(-1.5, 1.5),
color: Colors.black.withAlpha(138),
),
],
),
Shadow(
offset: const Offset(1.5, -1.5),
color: Colors.black.withAlpha(138),
),
Shadow(
offset: const Offset(1.5, 1.5),
color: Colors.black.withAlpha(138),
),
Shadow(
offset: const Offset(-1.5, 1.5),
color: Colors.black.withAlpha(138),
),
],
),
collapseMode: CollapseMode.parallax,
),
),
collapseMode: CollapseMode.parallax,
),
),
const SliverGap(20),
SliverSafeArea(
top: false,
sliver: SliverPadding(
padding: EdgeInsets.symmetric(
horizontal: mediaQuery.mdAndDown ? 12 : 24,
),
sliver: PlaybuttonView(
controller: scrollController,
itemCount: playlists.asData?.value.items.length ?? 0,
isLoading: playlists.isLoading,
hasMore: playlists.asData?.value.hasMore == true,
onRequestMore: playlistsNotifier.fetchMore,
listItemBuilder: (context, index) =>
PlaylistCard.tile(playlists.asData!.value.items[index]),
gridItemBuilder: (context, index) =>
PlaylistCard(playlists.asData!.value.items[index]),
const SliverGap(20),
SliverSafeArea(
top: false,
sliver: SliverPadding(
padding: EdgeInsets.symmetric(
horizontal: mediaQuery.mdAndDown ? 12 : 24,
),
sliver: PlaybuttonView(
controller: scrollController,
itemCount: playlists.asData?.value.items.length ?? 0,
isLoading: playlists.isLoading,
hasMore: playlists.asData?.value.hasMore == true,
onRequestMore: playlistsNotifier.fetchMore,
listItemBuilder: (context, index) => PlaylistCard.tile(
playlists.asData!.value.items[index]),
gridItemBuilder: (context, index) =>
PlaylistCard(playlists.asData!.value.items[index]),
),
),
),
),
const SliverGap(20),
],
const SliverGap(20),
],
),
),
),
),

View File

@ -32,7 +32,6 @@ class GenrePage extends HookConsumerWidget {
headers: [
TitleBar(
title: Text(context.l10n.explore_genres),
automaticallyImplyLeading: true,
)
],
child: GridView.builder(

View File

@ -31,6 +31,7 @@ class LastFMLoginPage extends HookConsumerWidget {
return Scaffold(
headers: const [
SafeArea(
bottom: false,
child: TitleBar(
leading: [BackButton()],
),
@ -39,102 +40,104 @@ class LastFMLoginPage extends HookConsumerWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
constraints: const BoxConstraints(maxWidth: 400),
alignment: Alignment.center,
padding: const EdgeInsets.all(16),
child: Card(
padding: const EdgeInsets.all(16.0),
child: Form(
onSubmit: (context, values) async {
try {
isLoading.value = true;
await scrobblerNotifier.login(
values[usernameKey].trim(),
values[passwordKey],
);
if (context.mounted) {
context.back();
}
} catch (e) {
if (context.mounted) {
showPromptDialog(
context: context,
title: context.l10n.error("Authentication failed"),
message: e.toString(),
cancelText: null,
Flexible(
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
alignment: Alignment.center,
padding: const EdgeInsets.all(16),
child: Card(
padding: const EdgeInsets.all(16.0),
child: Form(
onSubmit: (context, values) async {
try {
isLoading.value = true;
await scrobblerNotifier.login(
values[usernameKey].trim(),
values[passwordKey],
);
if (context.mounted) {
context.back();
}
} catch (e) {
if (context.mounted) {
showPromptDialog(
context: context,
title: context.l10n.error("Authentication failed"),
message: e.toString(),
cancelText: null,
);
}
} finally {
isLoading.value = false;
}
} finally {
isLoading.value = false;
}
},
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 10,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
color: const Color.fromARGB(255, 186, 0, 0),
},
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 10,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
color: const Color.fromARGB(255, 186, 0, 0),
),
padding: const EdgeInsets.all(12),
child: const Icon(
SpotubeIcons.lastFm,
color: Colors.white,
size: 60,
),
),
padding: const EdgeInsets.all(12),
child: const Icon(
SpotubeIcons.lastFm,
color: Colors.white,
size: 60,
),
),
const Text("last.fm").h3(),
Text(context.l10n.login_with_your_lastfm),
AutofillGroup(
child: Column(
spacing: 10,
children: [
FormField(
label: Text(context.l10n.username),
key: usernameKey,
validator: const NotEmptyValidator(),
child: TextField(
autofillHints: const [
AutofillHints.username,
AutofillHints.email,
],
placeholder: Text(context.l10n.username),
),
),
FormField(
key: passwordKey,
validator: const NotEmptyValidator(),
label: Text(context.l10n.password),
child: TextField(
autofillHints: const [
AutofillHints.password,
],
obscureText: !passwordVisible.value,
placeholder: Text(context.l10n.password),
trailing: IconButton.ghost(
icon: Icon(
passwordVisible.value
? SpotubeIcons.eye
: SpotubeIcons.noEye,
),
onPressed: () => passwordVisible.value =
!passwordVisible.value,
const Text("last.fm").h3(),
Text(context.l10n.login_with_your_lastfm),
AutofillGroup(
child: Column(
spacing: 10,
children: [
FormField(
label: Text(context.l10n.username),
key: usernameKey,
validator: const NotEmptyValidator(),
child: TextField(
autofillHints: const [
AutofillHints.username,
AutofillHints.email,
],
placeholder: Text(context.l10n.username),
),
),
),
],
FormField(
key: passwordKey,
validator: const NotEmptyValidator(),
label: Text(context.l10n.password),
child: TextField(
autofillHints: const [
AutofillHints.password,
],
obscureText: !passwordVisible.value,
placeholder: Text(context.l10n.password),
trailing: IconButton.ghost(
icon: Icon(
passwordVisible.value
? SpotubeIcons.eye
: SpotubeIcons.noEye,
),
onPressed: () => passwordVisible.value =
!passwordVisible.value,
),
),
),
],
),
),
),
FormErrorBuilder(builder: (context, errors, child) {
return Button.primary(
onPressed: () => context.submitForm(),
enabled: errors.isEmpty && !isLoading.value,
child: Text(context.l10n.login),
);
}),
],
FormErrorBuilder(builder: (context, errors, child) {
return Button.primary(
onPressed: () => context.submitForm(),
enabled: errors.isEmpty && !isLoading.value,
child: Text(context.l10n.login),
);
}),
],
),
),
),
),

View File

@ -256,426 +256,430 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
final controller = useScrollController();
return Scaffold(
headers: [
TitleBar(
leading: const [BackButton()],
title: Text(context.l10n.generate),
)
],
child: Scrollbar(
controller: controller,
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: Breakpoints.lg),
child: SafeArea(
child: LayoutBuilder(builder: (context, constrains) {
return ScrollConfiguration(
behavior: ScrollConfiguration.of(context)
.copyWith(scrollbars: false),
child: ListView(
controller: controller,
padding: const EdgeInsets.all(16),
children: [
ValueListenableBuilder(
valueListenable: limit,
builder: (context, value, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.number_of_tracks_generate,
style: typography.semiBold,
),
Row(
spacing: 5,
children: [
Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: theme.colorScheme.primary
.withAlpha(25),
shape: BoxShape.circle,
),
child: Text(
value.round().toString(),
style: typography.large.copyWith(
color: theme.colorScheme.primary,
return SafeArea(
bottom: false,
child: Scaffold(
headers: [
TitleBar(
leading: const [BackButton()],
title: Text(context.l10n.generate),
)
],
child: Scrollbar(
controller: controller,
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: Breakpoints.lg),
child: SafeArea(
child: LayoutBuilder(builder: (context, constrains) {
return ScrollConfiguration(
behavior: ScrollConfiguration.of(context)
.copyWith(scrollbars: false),
child: ListView(
controller: controller,
padding: const EdgeInsets.all(16),
children: [
ValueListenableBuilder(
valueListenable: limit,
builder: (context, value, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.number_of_tracks_generate,
style: typography.semiBold,
),
Row(
spacing: 5,
children: [
Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: theme.colorScheme.primary
.withAlpha(25),
shape: BoxShape.circle,
),
child: Text(
value.round().toString(),
style: typography.large.copyWith(
color: theme.colorScheme.primary,
),
),
),
),
Expanded(
child: Slider(
value:
SliderValue.single(value.toDouble()),
min: 10,
max: 100,
divisions: 9,
onChanged: (value) {
limit.value = value.value.round();
},
),
)
],
)
Expanded(
child: Slider(
value: SliderValue.single(
value.toDouble()),
min: 10,
max: 100,
divisions: 9,
onChanged: (value) {
limit.value = value.value.round();
},
),
)
],
)
],
);
},
),
const SizedBox(height: 16),
if (constrains.mdAndUp)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: countrySelector,
),
const SizedBox(width: 16),
Expanded(
child: genreSelector,
),
],
);
},
),
const SizedBox(height: 16),
if (constrains.mdAndUp)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: countrySelector,
),
const SizedBox(width: 16),
Expanded(
child: genreSelector,
),
],
)
else ...[
countrySelector,
)
else ...[
countrySelector,
const SizedBox(height: 16),
genreSelector,
],
const SizedBox(height: 16),
genreSelector,
],
const SizedBox(height: 16),
if (constrains.mdAndUp)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: artistAutoComplete,
),
const SizedBox(width: 16),
Expanded(
child: tracksAutocomplete,
),
],
)
else ...[
artistAutoComplete,
if (constrains.mdAndUp)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: artistAutoComplete,
),
const SizedBox(width: 16),
Expanded(
child: tracksAutocomplete,
),
],
)
else ...[
artistAutoComplete,
const SizedBox(height: 16),
tracksAutocomplete,
],
const SizedBox(height: 16),
tracksAutocomplete,
],
const SizedBox(height: 16),
RecommendationAttributeDials(
title: Text(context.l10n.acousticness),
values: (
target: target.value.acousticness?.toDouble() ?? 0,
min: min.value.acousticness?.toDouble() ?? 0,
max: max.value.acousticness?.toDouble() ?? 0,
),
onChanged: (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(
title: Text(context.l10n.danceability),
values: (
target: target.value.danceability?.toDouble() ?? 0,
min: min.value.danceability?.toDouble() ?? 0,
max: max.value.danceability?.toDouble() ?? 0,
),
onChanged: (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(
title: Text(context.l10n.energy),
values: (
target: target.value.energy?.toDouble() ?? 0,
min: min.value.energy?.toDouble() ?? 0,
max: max.value.energy?.toDouble() ?? 0,
),
onChanged: (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(
title: Text(context.l10n.instrumentalness),
values: (
target:
target.value.instrumentalness?.toDouble() ?? 0,
min: min.value.instrumentalness?.toDouble() ?? 0,
max: max.value.instrumentalness?.toDouble() ?? 0,
),
onChanged: (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(
title: Text(context.l10n.liveness),
values: (
target: target.value.liveness?.toDouble() ?? 0,
min: min.value.liveness?.toDouble() ?? 0,
max: max.value.liveness?.toDouble() ?? 0,
),
onChanged: (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(
title: Text(context.l10n.loudness),
values: (
target: target.value.loudness?.toDouble() ?? 0,
min: min.value.loudness?.toDouble() ?? 0,
max: max.value.loudness?.toDouble() ?? 0,
),
onChanged: (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(
title: Text(context.l10n.speechiness),
values: (
target: target.value.speechiness?.toDouble() ?? 0,
min: min.value.speechiness?.toDouble() ?? 0,
max: max.value.speechiness?.toDouble() ?? 0,
),
onChanged: (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(
title: Text(context.l10n.valence),
values: (
target: target.value.valence?.toDouble() ?? 0,
min: min.value.valence?.toDouble() ?? 0,
max: max.value.valence?.toDouble() ?? 0,
),
onChanged: (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(
title: Text(context.l10n.popularity),
base: 100,
values: (
target: target.value.popularity?.toDouble() ?? 0,
min: min.value.popularity?.toDouble() ?? 0,
max: max.value.popularity?.toDouble() ?? 0,
),
onChanged: (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(
title: Text(context.l10n.key),
base: 11,
values: (
target: target.value.key?.toDouble() ?? 0,
min: min.value.key?.toDouble() ?? 0,
max: max.value.key?.toDouble() ?? 0,
),
onChanged: (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(
title: Text(context.l10n.duration),
values: (
max: (max.value.durationMs ?? 0) / 1000,
target: (target.value.durationMs ?? 0) / 1000,
min: (min.value.durationMs ?? 0) / 1000,
),
onChanged: (value) {
target.value = target.value.copyWith(
durationMs: (value.target * 1000).toInt(),
);
min.value = min.value.copyWith(
durationMs: (value.min * 1000).toInt(),
);
max.value = max.value.copyWith(
durationMs: (value.max * 1000).toInt(),
);
},
presets: {
context.l10n.short: (min: 50, target: 90, max: 120),
context.l10n.medium: (
min: 120,
target: 180,
max: 200
RecommendationAttributeDials(
title: Text(context.l10n.acousticness),
values: (
target: target.value.acousticness?.toDouble() ?? 0,
min: min.value.acousticness?.toDouble() ?? 0,
max: max.value.acousticness?.toDouble() ?? 0,
),
context.l10n.long: (min: 480, target: 560, max: 640)
},
),
RecommendationAttributeFields(
title: Text(context.l10n.tempo),
values: (
max: max.value.tempo?.toDouble() ?? 0,
target: target.value.tempo?.toDouble() ?? 0,
min: min.value.tempo?.toDouble() ?? 0,
onChanged: (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,
);
},
),
onChanged: (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(
title: Text(context.l10n.mode),
values: (
max: max.value.mode?.toDouble() ?? 0,
target: target.value.mode?.toDouble() ?? 0,
min: min.value.mode?.toDouble() ?? 0,
RecommendationAttributeDials(
title: Text(context.l10n.danceability),
values: (
target: target.value.danceability?.toDouble() ?? 0,
min: min.value.danceability?.toDouble() ?? 0,
max: max.value.danceability?.toDouble() ?? 0,
),
onChanged: (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,
);
},
),
onChanged: (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(
title: Text(context.l10n.time_signature),
values: (
max: max.value.timeSignature?.toDouble() ?? 0,
target: target.value.timeSignature?.toDouble() ?? 0,
min: min.value.timeSignature?.toDouble() ?? 0,
RecommendationAttributeDials(
title: Text(context.l10n.energy),
values: (
target: target.value.energy?.toDouble() ?? 0,
min: min.value.energy?.toDouble() ?? 0,
max: max.value.energy?.toDouble() ?? 0,
),
onChanged: (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,
);
},
),
onChanged: (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 Gap(20),
Center(
child: Button.primary(
leading: const Icon(SpotubeIcons.magic),
onPressed: artists.value.isEmpty &&
tracks.value.isEmpty &&
genres.value.isEmpty
? null
: () {
final routeState =
GeneratePlaylistProviderInput(
seedArtists: artists.value
.map((a) => a.id!)
.toList(),
seedTracks:
tracks.value.map((t) => t.id!).toList(),
seedGenres: genres.value,
limit: limit.value,
max: max.value,
min: min.value,
target: target.value,
);
context.navigateTo(
PlaylistGenerateResultRoute(
state: routeState,
),
);
},
child: Text(context.l10n.generate),
RecommendationAttributeDials(
title: Text(context.l10n.instrumentalness),
values: (
target:
target.value.instrumentalness?.toDouble() ?? 0,
min: min.value.instrumentalness?.toDouble() ?? 0,
max: max.value.instrumentalness?.toDouble() ?? 0,
),
onChanged: (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(
title: Text(context.l10n.liveness),
values: (
target: target.value.liveness?.toDouble() ?? 0,
min: min.value.liveness?.toDouble() ?? 0,
max: max.value.liveness?.toDouble() ?? 0,
),
onChanged: (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(
title: Text(context.l10n.loudness),
values: (
target: target.value.loudness?.toDouble() ?? 0,
min: min.value.loudness?.toDouble() ?? 0,
max: max.value.loudness?.toDouble() ?? 0,
),
onChanged: (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(
title: Text(context.l10n.speechiness),
values: (
target: target.value.speechiness?.toDouble() ?? 0,
min: min.value.speechiness?.toDouble() ?? 0,
max: max.value.speechiness?.toDouble() ?? 0,
),
onChanged: (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(
title: Text(context.l10n.valence),
values: (
target: target.value.valence?.toDouble() ?? 0,
min: min.value.valence?.toDouble() ?? 0,
max: max.value.valence?.toDouble() ?? 0,
),
onChanged: (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(
title: Text(context.l10n.popularity),
base: 100,
values: (
target: target.value.popularity?.toDouble() ?? 0,
min: min.value.popularity?.toDouble() ?? 0,
max: max.value.popularity?.toDouble() ?? 0,
),
onChanged: (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(
title: Text(context.l10n.key),
base: 11,
values: (
target: target.value.key?.toDouble() ?? 0,
min: min.value.key?.toDouble() ?? 0,
max: max.value.key?.toDouble() ?? 0,
),
onChanged: (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(
title: Text(context.l10n.duration),
values: (
max: (max.value.durationMs ?? 0) / 1000,
target: (target.value.durationMs ?? 0) / 1000,
min: (min.value.durationMs ?? 0) / 1000,
),
onChanged: (value) {
target.value = target.value.copyWith(
durationMs: (value.target * 1000).toInt(),
);
min.value = min.value.copyWith(
durationMs: (value.min * 1000).toInt(),
);
max.value = max.value.copyWith(
durationMs: (value.max * 1000).toInt(),
);
},
presets: {
context.l10n.short: (min: 50, target: 90, max: 120),
context.l10n.medium: (
min: 120,
target: 180,
max: 200
),
context.l10n.long: (min: 480, target: 560, max: 640)
},
),
RecommendationAttributeFields(
title: Text(context.l10n.tempo),
values: (
max: max.value.tempo?.toDouble() ?? 0,
target: target.value.tempo?.toDouble() ?? 0,
min: min.value.tempo?.toDouble() ?? 0,
),
onChanged: (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(
title: Text(context.l10n.mode),
values: (
max: max.value.mode?.toDouble() ?? 0,
target: target.value.mode?.toDouble() ?? 0,
min: min.value.mode?.toDouble() ?? 0,
),
onChanged: (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(
title: Text(context.l10n.time_signature),
values: (
max: max.value.timeSignature?.toDouble() ?? 0,
target: target.value.timeSignature?.toDouble() ?? 0,
min: min.value.timeSignature?.toDouble() ?? 0,
),
onChanged: (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 Gap(20),
Center(
child: Button.primary(
leading: const Icon(SpotubeIcons.magic),
onPressed: artists.value.isEmpty &&
tracks.value.isEmpty &&
genres.value.isEmpty
? null
: () {
final routeState =
GeneratePlaylistProviderInput(
seedArtists: artists.value
.map((a) => a.id!)
.toList(),
seedTracks: tracks.value
.map((t) => t.id!)
.toList(),
seedGenres: genres.value,
limit: limit.value,
max: max.value,
min: min.value,
target: target.value,
);
context.navigateTo(
PlaylistGenerateResultRoute(
state: routeState,
),
);
},
child: Text(context.l10n.generate),
),
),
],
),
);
}),
),
),
),
),

View File

@ -48,218 +48,225 @@ class PlaylistGenerateResultPage extends HookConsumerWidget {
final isAllTrackSelected = selectedTracks.value.length ==
(generatedPlaylist.asData?.value.length ?? 0);
return Scaffold(
headers: const [
TitleBar(leading: [BackButton()])
],
child: generatedPlaylist.isLoading
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const CircularProgressIndicator(),
Text(context.l10n.generating_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: [
Button.primary(
leading: const Icon(SpotubeIcons.play),
onPressed: selectedTracks.value.isEmpty
? null
: () async {
await playlistNotifier.load(
generatedPlaylist.asData!.value
.where(
(e) => selectedTracks.value
.contains(e.id!),
)
.toList(),
autoPlay: true,
);
},
child: Text(context.l10n.play),
return SafeArea(
bottom: false,
child: Scaffold(
headers: const [
TitleBar(leading: [BackButton()])
],
child: generatedPlaylist.isLoading
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const CircularProgressIndicator(),
Text(context.l10n.generating_playlist),
],
),
)
: Padding(
padding: const EdgeInsets.all(8.0),
child: ListView(
children: [
GridView(
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
mainAxisExtent: 32,
),
Button.primary(
leading: const Icon(SpotubeIcons.queueAdd),
onPressed: selectedTracks.value.isEmpty
? null
: () async {
await playlistNotifier.addTracks(
generatedPlaylist.asData!.value.where(
(e) => selectedTracks.value.contains(e.id!),
),
);
if (context.mounted) {
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Text(
context.l10n.add_count_to_queue(
selectedTracks.value.length,
),
),
);
},
);
}
},
child: Text(context.l10n.add_to_queue),
),
Button.primary(
leading: const Icon(SpotubeIcons.addFilled),
onPressed: selectedTracks.value.isEmpty
? null
: () async {
final playlist = await showDialog<Playlist>(
context: context,
builder: (context) => PlaylistCreateDialog(
trackIds: selectedTracks.value,
),
);
if (playlist != null && context.mounted) {
context.navigateTo(
PlaylistRoute(
id: playlist.id!,
playlist: playlist,
),
);
}
},
child: Text(context.l10n.create_a_playlist),
),
Button.primary(
leading: const Icon(SpotubeIcons.playlistAdd),
onPressed: selectedTracks.value.isEmpty
? null
: () async {
final hasAdded = await showDialog<bool>(
context: context,
builder: (context) => PlaylistAddTrackDialog(
openFromPlaylist: null,
tracks: selectedTracks.value
.map(
(e) => generatedPlaylist.asData!.value
.firstWhere(
(element) => element.id == e,
),
shrinkWrap: true,
children: [
Button.primary(
leading: const Icon(SpotubeIcons.play),
onPressed: selectedTracks.value.isEmpty
? null
: () async {
await playlistNotifier.load(
generatedPlaylist.asData!.value
.where(
(e) => selectedTracks.value
.contains(e.id!),
)
.toList(),
),
);
if (context.mounted && hasAdded == true) {
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Text(
context.l10n.add_count_to_playlist(
selectedTracks.value.length,
),
),
);
},
autoPlay: true,
);
}
},
child: Text(context.l10n.add_to_playlist),
)
],
),
const SizedBox(height: 16),
if (generatedPlaylist.asData?.value != null)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.l10n.selected_count_tracks(
selectedTracks.value.length,
),
},
child: Text(context.l10n.play),
),
Button.secondary(
onPressed: () {
if (isAllTrackSelected) {
selectedTracks.value = [];
} else {
selectedTracks.value = generatedPlaylist
.asData?.value
.map((e) => e.id!)
.toList() ??
[];
}
},
leading: const Icon(SpotubeIcons.selectionCheck),
child: Text(
isAllTrackSelected
? context.l10n.deselect_all
: context.l10n.select_all,
),
Button.primary(
leading: const Icon(SpotubeIcons.queueAdd),
onPressed: selectedTracks.value.isEmpty
? null
: () async {
await playlistNotifier.addTracks(
generatedPlaylist.asData!.value.where(
(e) =>
selectedTracks.value.contains(e.id!),
),
);
if (context.mounted) {
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Text(
context.l10n.add_count_to_queue(
selectedTracks.value.length,
),
),
);
},
);
}
},
child: Text(context.l10n.add_to_queue),
),
Button.primary(
leading: const Icon(SpotubeIcons.addFilled),
onPressed: selectedTracks.value.isEmpty
? null
: () async {
final playlist = await showDialog<Playlist>(
context: context,
builder: (context) => PlaylistCreateDialog(
trackIds: selectedTracks.value,
),
);
if (playlist != null && context.mounted) {
context.navigateTo(
PlaylistRoute(
id: playlist.id!,
playlist: playlist,
),
);
}
},
child: Text(context.l10n.create_a_playlist),
),
Button.primary(
leading: const Icon(SpotubeIcons.playlistAdd),
onPressed: selectedTracks.value.isEmpty
? null
: () async {
final hasAdded = await showDialog<bool>(
context: context,
builder: (context) =>
PlaylistAddTrackDialog(
openFromPlaylist: null,
tracks: selectedTracks.value
.map(
(e) => generatedPlaylist
.asData!.value
.firstWhere(
(element) => element.id == e,
),
)
.toList(),
),
);
if (context.mounted && hasAdded == true) {
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Text(
context.l10n.add_count_to_playlist(
selectedTracks.value.length,
),
),
);
},
);
}
},
child: Text(context.l10n.add_to_playlist),
)
],
),
const SizedBox(height: 8),
SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final track
in generatedPlaylist.asData?.value ?? [])
Row(
spacing: 5,
children: [
Checkbox(
state: selectedTracks.value.contains(track.id)
? CheckboxState.checked
: CheckboxState.unchecked,
onChanged: (value) {
if (value == CheckboxState.checked) {
selectedTracks.value.add(track.id!);
} else {
selectedTracks.value.remove(track.id);
}
selectedTracks.value =
selectedTracks.value.toList();
},
),
Expanded(
child: GestureDetector(
onTap: () {
selectedTracks.value.contains(track.id)
? selectedTracks.value.remove(track.id)
: selectedTracks.value.add(track.id!);
const SizedBox(height: 16),
if (generatedPlaylist.asData?.value != null)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.l10n.selected_count_tracks(
selectedTracks.value.length,
),
),
Button.secondary(
onPressed: () {
if (isAllTrackSelected) {
selectedTracks.value = [];
} else {
selectedTracks.value = generatedPlaylist
.asData?.value
.map((e) => e.id!)
.toList() ??
[];
}
},
leading: const Icon(SpotubeIcons.selectionCheck),
child: Text(
isAllTrackSelected
? context.l10n.deselect_all
: context.l10n.select_all,
),
),
],
),
const SizedBox(height: 8),
SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final track
in generatedPlaylist.asData?.value ?? [])
Row(
spacing: 5,
children: [
Checkbox(
state: selectedTracks.value.contains(track.id)
? CheckboxState.checked
: CheckboxState.unchecked,
onChanged: (value) {
if (value == CheckboxState.checked) {
selectedTracks.value.add(track.id!);
} else {
selectedTracks.value.remove(track.id);
}
selectedTracks.value =
selectedTracks.value.toList();
},
child: SimpleTrackTile(track: track),
),
),
],
)
],
Expanded(
child: GestureDetector(
onTap: () {
selectedTracks.value.contains(track.id)
? selectedTracks.value
.remove(track.id)
: selectedTracks.value.add(track.id!);
selectedTracks.value =
selectedTracks.value.toList();
},
child: SimpleTrackTile(track: track),
),
),
],
)
],
),
),
),
],
],
),
),
),
),
);
}
}

View File

@ -27,50 +27,53 @@ class WebViewLoginPage extends HookConsumerWidget {
);
}
return Scaffold(
headers: const [
TitleBar(
leading: [BackButton(color: Colors.white)],
backgroundColor: Colors.transparent,
),
],
floatingHeader: true,
child: InAppWebView(
initialSettings: InAppWebViewSettings(
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 safari/537.36",
),
initialUrlRequest: URLRequest(
url: WebUri("https://accounts.spotify.com/"),
),
onPermissionRequest: (controller, permissionRequest) async {
return PermissionResponse(
resources: permissionRequest.resources,
action: PermissionResponseAction.GRANT,
);
},
onLoadStop: (controller, action) async {
if (action == null) return;
String url = action.toString();
if (url.endsWith("/")) {
url = url.substring(0, url.length - 1);
}
final exp = RegExp(r"https:\/\/accounts.spotify.com\/.+\/status");
if (exp.hasMatch(url)) {
final cookies =
await CookieManager.instance().getCookies(url: action);
final cookieHeader =
"sp_dc=${cookies.firstWhere((element) => element.name == "sp_dc").value}";
await authenticationNotifier.login(cookieHeader);
if (context.mounted) {
// ignore: use_build_context_synchronously
context.navigateTo(const HomeRoute());
return SafeArea(
bottom: false,
child: Scaffold(
headers: const [
TitleBar(
leading: [BackButton(color: Colors.white)],
backgroundColor: Colors.transparent,
),
],
floatingHeader: true,
child: InAppWebView(
initialSettings: InAppWebViewSettings(
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 safari/537.36",
),
initialUrlRequest: URLRequest(
url: WebUri("https://accounts.spotify.com/"),
),
onPermissionRequest: (controller, permissionRequest) async {
return PermissionResponse(
resources: permissionRequest.resources,
action: PermissionResponseAction.GRANT,
);
},
onLoadStop: (controller, action) async {
if (action == null) return;
String url = action.toString();
if (url.endsWith("/")) {
url = url.substring(0, url.length - 1);
}
}
},
final exp = RegExp(r"https:\/\/accounts.spotify.com\/.+\/status");
if (exp.hasMatch(url)) {
final cookies =
await CookieManager.instance().getCookies(url: action);
final cookieHeader =
"sp_dc=${cookies.firstWhere((element) => element.name == "sp_dc").value}";
await authenticationNotifier.login(cookieHeader);
if (context.mounted) {
// ignore: use_build_context_synchronously
context.navigateTo(const HomeRoute());
}
}
},
),
),
);
}

View File

@ -44,7 +44,6 @@ class ProfilePage extends HookConsumerWidget {
headers: [
TitleBar(
title: Text(context.l10n.profile),
automaticallyImplyLeading: true,
)
],
child: Skeletonizer(

View File

@ -31,163 +31,166 @@ class AboutSpotubePage extends HookConsumerWidget {
const colon = TableCell(child: Text(":"));
return Scaffold(
headers: [
TitleBar(
leading: const [BackButton()],
title: Text(context.l10n.about_spotube),
)
],
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: [
Assets.spotubeLogoPng.image(
height: 200,
width: 200,
),
Center(
child: Column(
children: [
Text(context.l10n.spotube_description).semiBold().large(),
const SizedBox(height: 20),
Table(
columnWidths: const {
0: FixedTableSize(95),
1: FixedTableSize(10),
2: IntrinsicTableSize(),
},
defaultRowHeight: const FixedTableSize(40),
rows: [
TableRow(
cells: [
TableCell(child: Text(context.l10n.founder)),
colon,
TableCell(
child: Hyperlink(
context.l10n.kingkor_roy_tirtho,
"https://github.com/KRTirtho",
return SafeArea(
bottom: false,
child: Scaffold(
headers: [
TitleBar(
leading: const [BackButton()],
title: Text(context.l10n.about_spotube),
)
],
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: [
Assets.spotubeLogoPng.image(
height: 200,
width: 200,
),
Center(
child: Column(
children: [
Text(context.l10n.spotube_description).semiBold().large(),
const SizedBox(height: 20),
Table(
columnWidths: const {
0: FixedTableSize(95),
1: FixedTableSize(10),
2: IntrinsicTableSize(),
},
defaultRowHeight: const FixedTableSize(40),
rows: [
TableRow(
cells: [
TableCell(child: Text(context.l10n.founder)),
colon,
TableCell(
child: Hyperlink(
context.l10n.kingkor_roy_tirtho,
"https://github.com/KRTirtho",
),
)
],
),
TableRow(
cells: [
TableCell(child: Text(context.l10n.version)),
colon,
TableCell(child: Text("v${packageInfo.version}"))
],
),
TableRow(
cells: [
TableCell(child: Text(context.l10n.channel)),
colon,
TableCell(child: Text(Env.releaseChannel.name))
],
),
TableRow(
cells: [
TableCell(child: Text(context.l10n.build_number)),
colon,
TableCell(
child: Text(packageInfo.buildNumber
.replaceAll(".", " ")),
)
],
),
TableRow(
cells: [
TableCell(child: Text(context.l10n.repository)),
colon,
const TableCell(
child: Hyperlink(
"github.com/KRTirtho/spotube",
"https://github.com/KRTirtho/spotube",
),
),
)
],
),
TableRow(
cells: [
TableCell(child: Text(context.l10n.version)),
colon,
TableCell(child: Text("v${packageInfo.version}"))
],
),
TableRow(
cells: [
TableCell(child: Text(context.l10n.channel)),
colon,
TableCell(child: Text(Env.releaseChannel.name))
],
),
TableRow(
cells: [
TableCell(child: Text(context.l10n.build_number)),
colon,
TableCell(
child: Text(
packageInfo.buildNumber.replaceAll(".", " ")),
)
],
),
TableRow(
cells: [
TableCell(child: Text(context.l10n.repository)),
colon,
const TableCell(
child: Hyperlink(
"github.com/KRTirtho/spotube",
"https://github.com/KRTirtho/spotube",
],
),
TableRow(
cells: [
TableCell(child: Text(context.l10n.license)),
colon,
const TableCell(
child: Hyperlink(
"BSD-4-Clause",
"https://raw.githubusercontent.com/KRTirtho/spotube/master/LICENSE",
),
),
),
],
),
TableRow(
cells: [
TableCell(child: Text(context.l10n.license)),
colon,
const TableCell(
child: Hyperlink(
"BSD-4-Clause",
"https://raw.githubusercontent.com/KRTirtho/spotube/master/LICENSE",
],
),
TableRow(
cells: [
TableCell(child: Text(context.l10n.bug_issues)),
colon,
const TableCell(
child: Hyperlink(
"github.com/KRTirtho/spotube/issues",
"https://github.com/KRTirtho/spotube/issues",
),
),
),
],
),
TableRow(
cells: [
TableCell(child: Text(context.l10n.bug_issues)),
colon,
const TableCell(
child: Hyperlink(
"github.com/KRTirtho/spotube/issues",
"https://github.com/KRTirtho/spotube/issues",
),
),
],
),
],
],
),
],
),
],
),
),
const SizedBox(height: 20),
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => launchUrl(
Uri.parse("https://discord.gg/uJ94vxB6vg"),
mode: LaunchMode.externalApplication,
),
child: const UniversalImage(
path:
"https://discord.com/api/guilds/1012234096237350943/widget.png?style=banner2",
),
],
),
),
const SizedBox(height: 20),
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => launchUrl(
Uri.parse("https://discord.gg/uJ94vxB6vg"),
mode: LaunchMode.externalApplication,
),
child: const UniversalImage(
path:
"https://discord.com/api/guilds/1012234096237350943/widget.png?style=banner2",
),
),
),
const SizedBox(height: 20),
Text(
context.l10n.made_with,
textAlign: TextAlign.center,
style: theme.typography.small,
),
Text(
context.l10n.copyright(DateTime.now().year),
textAlign: TextAlign.center,
style: theme.typography.small,
),
const SizedBox(height: 20),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 750),
child: SafeArea(
child: license.when(
data: (data) {
return Text(
data,
style: theme.typography.small,
);
},
loading: () {
return const Center(
child: CircularProgressIndicator(),
);
},
error: (e, s) {
return Text(
e.toString(),
style: theme.typography.small,
);
},
const SizedBox(height: 20),
Text(
context.l10n.made_with,
textAlign: TextAlign.center,
style: theme.typography.small,
),
Text(
context.l10n.copyright(DateTime.now().year),
textAlign: TextAlign.center,
style: theme.typography.small,
),
const SizedBox(height: 20),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 750),
child: SafeArea(
child: license.when(
data: (data) {
return Text(
data,
style: theme.typography.small,
);
},
loading: () {
return const Center(
child: CircularProgressIndicator(),
);
},
error: (e, s) {
return Text(
e.toString(),
style: theme.typography.small,
);
},
),
),
),
),
],
],
),
),
),
),

View File

@ -47,50 +47,52 @@ class BlackListPage extends HookConsumerWidget {
[blacklist, searchText.value],
);
return Scaffold(
headers: [
TitleBar(
title: Text(context.l10n.blacklist),
leading: const [BackButton()],
)
],
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
onChanged: (value) => searchText.value = value,
placeholder: Text(context.l10n.search),
leading: const Icon(SpotubeIcons.search),
),
),
InterScrollbar(
controller: controller,
child: ListView.builder(
controller: controller,
shrinkWrap: true,
itemCount: filteredBlacklist.length,
itemBuilder: (context, index) {
final item = filteredBlacklist.elementAt(index);
return ButtonTile(
style: ButtonVariance.ghost,
leading: Text("${index + 1}."),
title: Text("${item.name} (${item.elementType.name})"),
subtitle: Text(item.elementId),
trailing: IconButton.ghost(
icon: Icon(SpotubeIcons.trash, color: Colors.red[400]),
onPressed: () {
ref
.read(blacklistProvider.notifier)
.remove(filteredBlacklist.elementAt(index).elementId);
},
),
);
},
),
),
return SafeArea(
bottom: false,
child: Scaffold(
headers: [
TitleBar(
title: Text(context.l10n.blacklist),
leading: const [BackButton()],
)
],
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
onChanged: (value) => searchText.value = value,
placeholder: Text(context.l10n.search),
leading: const Icon(SpotubeIcons.search),
),
),
InterScrollbar(
controller: controller,
child: ListView.builder(
controller: controller,
shrinkWrap: true,
itemCount: filteredBlacklist.length,
itemBuilder: (context, index) {
final item = filteredBlacklist.elementAt(index);
return ButtonTile(
style: ButtonVariance.ghost,
leading: Text("${index + 1}."),
title: Text("${item.name} (${item.elementType.name})"),
subtitle: Text(item.elementId),
trailing: IconButton.ghost(
icon: Icon(SpotubeIcons.trash, color: Colors.red[400]),
onPressed: () {
ref.read(blacklistProvider.notifier).remove(
filteredBlacklist.elementAt(index).elementId);
},
),
);
},
),
),
],
),
),
);
}

View File

@ -34,7 +34,6 @@ class SettingsPage extends HookConsumerWidget {
headers: [
TitleBar(
title: Text(context.l10n.settings),
automaticallyImplyLeading: true,
)
],
child: Scrollbar(

View File

@ -26,31 +26,33 @@ class StatsAlbumsPage extends HookConsumerWidget {
final albumsData = topAlbums.asData?.value.items ?? [];
return Scaffold(
headers: [
TitleBar(
automaticallyImplyLeading: true,
title: Text(context.l10n.albums),
)
],
child: Skeletonizer(
enabled: topAlbums.isLoading && !topAlbums.isLoadingNextPage,
child: InfiniteList(
onFetchData: () async {
await topAlbumsNotifier.fetchMore();
},
hasError: topAlbums.hasError,
isLoading: topAlbums.isLoading && !topAlbums.isLoadingNextPage,
hasReachedMax: topAlbums.asData?.value.hasMore ?? true,
itemCount: albumsData.length,
itemBuilder: (context, index) {
final album = albumsData[index];
return StatsAlbumItem(
album: album.album,
info: Text(context.l10n
.count_plays(compactNumberFormatter.format(album.count))),
);
},
return SafeArea(
bottom: false,
child: Scaffold(
headers: [
TitleBar(
title: Text(context.l10n.albums),
)
],
child: Skeletonizer(
enabled: topAlbums.isLoading && !topAlbums.isLoadingNextPage,
child: InfiniteList(
onFetchData: () async {
await topAlbumsNotifier.fetchMore();
},
hasError: topAlbums.hasError,
isLoading: topAlbums.isLoading && !topAlbums.isLoadingNextPage,
hasReachedMax: topAlbums.asData?.value.hasMore ?? true,
itemCount: albumsData.length,
itemBuilder: (context, index) {
final album = albumsData[index];
return StatsAlbumItem(
album: album.album,
info: Text(context.l10n
.count_plays(compactNumberFormatter.format(album.count))),
);
},
),
),
),
);

View File

@ -29,31 +29,33 @@ class StatsArtistsPage extends HookConsumerWidget {
final artistsData = useMemoized(
() => topTracks.asData?.value.artists ?? [], [topTracks.asData?.value]);
return Scaffold(
headers: [
TitleBar(
automaticallyImplyLeading: true,
title: Text(context.l10n.artists),
)
],
child: Skeletonizer(
enabled: topTracks.isLoading && !topTracks.isLoadingNextPage,
child: InfiniteList(
onFetchData: () async {
await topTracksNotifier.fetchMore();
},
hasError: topTracks.hasError,
isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage,
hasReachedMax: topTracks.asData?.value.hasMore ?? true,
itemCount: artistsData.length,
itemBuilder: (context, index) {
final artist = artistsData[index];
return StatsArtistItem(
artist: artist.artist,
info: Text(context.l10n
.count_plays(compactNumberFormatter.format(artist.count))),
);
},
return SafeArea(
bottom: false,
child: Scaffold(
headers: [
TitleBar(
title: Text(context.l10n.artists),
)
],
child: Skeletonizer(
enabled: topTracks.isLoading && !topTracks.isLoadingNextPage,
child: InfiniteList(
onFetchData: () async {
await topTracksNotifier.fetchMore();
},
hasError: topTracks.hasError,
isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage,
hasReachedMax: topTracks.asData?.value.hasMore ?? true,
itemCount: artistsData.length,
itemBuilder: (context, index) {
final artist = artistsData[index];
return StatsArtistItem(
artist: artist.artist,
info: Text(context.l10n
.count_plays(compactNumberFormatter.format(artist.count))),
);
},
),
),
),
);

View File

@ -50,79 +50,83 @@ class StatsStreamFeesPage extends HookConsumerWidget {
HistoryDuration.allTime: context.l10n.all_time,
};
return Scaffold(
headers: [
TitleBar(
automaticallyImplyLeading: true,
title: Text(context.l10n.streaming_fees_hypothetical),
)
],
child: CustomScrollView(
slivers: [
SliverCrossAxisConstrained(
maxCrossAxisExtent: 600,
alignment: -1,
child: SliverPadding(
padding: const EdgeInsets.all(16.0),
sliver: SliverToBoxAdapter(
child: Text(
context.l10n.spotify_hipotetical_calculation,
).small().muted(),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.l10n.total_money(usdFormatter.format(total)),
).semiBold().large(),
Select<HistoryDuration>(
value: duration.value,
onChanged: (value) {
if (value == null) return;
duration.value = value;
},
itemBuilder: (context, value) => Text(translations[value]!),
constraints: const BoxConstraints(maxWidth: 150),
popupWidthConstraint: PopoverConstraint.anchorMaxSize,
children: [
for (final entry in translations.entries)
SelectItemButton(
value: entry.key,
child: Text(entry.value),
),
],
),
],
),
),
),
SliverSafeArea(
sliver: Skeletonizer.sliver(
enabled: topTracks.isLoading && !topTracks.isLoadingNextPage,
child: SliverInfiniteList(
onFetchData: () async {
await topTracksNotifier.fetchMore();
},
hasError: topTracks.hasError,
isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage,
hasReachedMax: topTracks.asData?.value.hasMore ?? true,
itemCount: artistsData.length,
itemBuilder: (context, index) {
final artist = artistsData[index];
return StatsArtistItem(
artist: artist.artist,
info: Text(usdFormatter.format(artist.count * 0.005)),
);
},
),
),
),
return SafeArea(
bottom: false,
child: Scaffold(
headers: [
TitleBar(
title: Text(context.l10n.streaming_fees_hypothetical),
)
],
child: CustomScrollView(
slivers: [
SliverCrossAxisConstrained(
maxCrossAxisExtent: 600,
alignment: -1,
child: SliverPadding(
padding: const EdgeInsets.all(16.0),
sliver: SliverToBoxAdapter(
child: Text(
context.l10n.spotify_hipotetical_calculation,
).small().muted(),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.l10n.total_money(usdFormatter.format(total)),
).semiBold().large(),
Select<HistoryDuration>(
value: duration.value,
onChanged: (value) {
if (value == null) return;
duration.value = value;
},
itemBuilder: (context, value) =>
Text(translations[value]!),
constraints: const BoxConstraints(maxWidth: 150),
popupWidthConstraint: PopoverConstraint.anchorMaxSize,
children: [
for (final entry in translations.entries)
SelectItemButton(
value: entry.key,
child: Text(entry.value),
),
],
),
],
),
),
),
SliverSafeArea(
sliver: Skeletonizer.sliver(
enabled: topTracks.isLoading && !topTracks.isLoadingNextPage,
child: SliverInfiniteList(
onFetchData: () async {
await topTracksNotifier.fetchMore();
},
hasError: topTracks.hasError,
isLoading:
topTracks.isLoading && !topTracks.isLoadingNextPage,
hasReachedMax: topTracks.asData?.value.hasMore ?? true,
itemCount: artistsData.length,
itemBuilder: (context, index) {
final artist = artistsData[index];
return StatsArtistItem(
artist: artist.artist,
info: Text(usdFormatter.format(artist.count * 0.005)),
);
},
),
),
),
],
),
),
);
}

View File

@ -28,34 +28,36 @@ class StatsMinutesPage extends HookConsumerWidget {
final tracksData = topTracks.asData?.value.items ?? [];
return Scaffold(
headers: [
TitleBar(
title: Text(context.l10n.minutes_listened),
automaticallyImplyLeading: true,
)
],
child: Skeletonizer(
enabled: topTracks.isLoading && !topTracks.isLoadingNextPage,
child: InfiniteList(
separatorBuilder: (context, index) => const Gap(8),
onFetchData: () async {
await topTracksNotifier.fetchMore();
},
hasError: topTracks.hasError,
isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage,
hasReachedMax: topTracks.asData?.value.hasMore ?? true,
itemCount: tracksData.length,
itemBuilder: (context, index) {
final track = tracksData[index];
return StatsTrackItem(
track: track.track,
info: Text(
context.l10n.count_mins(compactNumberFormatter
.format(track.count * track.track.duration!.inMinutes)),
),
);
},
return SafeArea(
bottom: false,
child: Scaffold(
headers: [
TitleBar(
title: Text(context.l10n.minutes_listened),
)
],
child: Skeletonizer(
enabled: topTracks.isLoading && !topTracks.isLoadingNextPage,
child: InfiniteList(
separatorBuilder: (context, index) => const Gap(8),
onFetchData: () async {
await topTracksNotifier.fetchMore();
},
hasError: topTracks.hasError,
isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage,
hasReachedMax: topTracks.asData?.value.hasMore ?? true,
itemCount: tracksData.length,
itemBuilder: (context, index) {
final track = tracksData[index];
return StatsTrackItem(
track: track.track,
info: Text(
context.l10n.count_mins(compactNumberFormatter
.format(track.count * track.track.duration!.inMinutes)),
),
);
},
),
),
),
);

View File

@ -27,33 +27,36 @@ class StatsPlaylistsPage extends HookConsumerWidget {
final playlistsData = topPlaylists.asData?.value.items ?? [];
return Scaffold(
headers: [
TitleBar(
automaticallyImplyLeading: true,
title: Text(context.l10n.playlists),
)
],
child: Skeletonizer(
enabled: topPlaylists.isLoading && !topPlaylists.isLoadingNextPage,
child: InfiniteList(
onFetchData: () async {
await topPlaylistsNotifier.fetchMore();
},
hasError: topPlaylists.hasError,
isLoading: topPlaylists.isLoading && !topPlaylists.isLoadingNextPage,
hasReachedMax: topPlaylists.asData?.value.hasMore ?? true,
itemCount: playlistsData.length,
itemBuilder: (context, index) {
final playlist = playlistsData[index];
return StatsPlaylistItem(
playlist: playlist.playlist,
info: Text(
context.l10n
.count_plays(compactNumberFormatter.format(playlist.count)),
),
);
},
return SafeArea(
bottom: false,
child: Scaffold(
headers: [
TitleBar(
title: Text(context.l10n.playlists),
)
],
child: Skeletonizer(
enabled: topPlaylists.isLoading && !topPlaylists.isLoadingNextPage,
child: InfiniteList(
onFetchData: () async {
await topPlaylistsNotifier.fetchMore();
},
hasError: topPlaylists.hasError,
isLoading:
topPlaylists.isLoading && !topPlaylists.isLoadingNextPage,
hasReachedMax: topPlaylists.asData?.value.hasMore ?? true,
itemCount: playlistsData.length,
itemBuilder: (context, index) {
final playlist = playlistsData[index];
return StatsPlaylistItem(
playlist: playlist.playlist,
info: Text(
context.l10n.count_plays(
compactNumberFormatter.format(playlist.count)),
),
);
},
),
),
),
);

View File

@ -28,34 +28,36 @@ class StatsStreamsPage extends HookConsumerWidget {
final tracksData = topTracks.asData?.value.items ?? [];
return Scaffold(
headers: [
TitleBar(
title: Text(context.l10n.streamed_songs),
automaticallyImplyLeading: true,
)
],
child: Skeletonizer(
enabled: topTracks.isLoading && !topTracks.isLoadingNextPage,
child: InfiniteList(
separatorBuilder: (context, index) => const Gap(8),
onFetchData: () async {
await topTracksNotifier.fetchMore();
},
hasError: topTracks.hasError,
isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage,
hasReachedMax: topTracks.asData?.value.hasMore ?? true,
itemCount: tracksData.length,
itemBuilder: (context, index) {
final track = tracksData[index];
return StatsTrackItem(
track: track.track,
info: Text(
context.l10n
.count_plays(compactNumberFormatter.format(track.count)),
),
);
},
return SafeArea(
bottom: false,
child: Scaffold(
headers: [
TitleBar(
title: Text(context.l10n.streamed_songs),
)
],
child: Skeletonizer(
enabled: topTracks.isLoading && !topTracks.isLoadingNextPage,
child: InfiniteList(
separatorBuilder: (context, index) => const Gap(8),
onFetchData: () async {
await topTracksNotifier.fetchMore();
},
hasError: topTracks.hasError,
isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage,
hasReachedMax: topTracks.asData?.value.hasMore ?? true,
itemCount: tracksData.length,
itemBuilder: (context, index) {
final track = tracksData[index];
return StatsTrackItem(
track: track.track,
info: Text(
context.l10n
.count_plays(compactNumberFormatter.format(track.count)),
),
);
},
),
),
),
);

View File

@ -54,197 +54,200 @@ class TrackPage extends HookConsumerWidget {
}
}
return Scaffold(
headers: const [
TitleBar(
automaticallyImplyLeading: true,
backgroundColor: Colors.transparent,
surfaceBlur: 0,
)
],
floatingHeader: true,
child: Stack(
children: [
Positioned.fill(
child: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: UniversalImage.imageProvider(
track.album!.images.asUrlString(
placeholder: ImagePlaceholder.albumArt,
return SafeArea(
bottom: false,
child: Scaffold(
headers: const [
TitleBar(
backgroundColor: Colors.transparent,
surfaceBlur: 0,
)
],
floatingHeader: true,
child: Stack(
children: [
Positioned.fill(
child: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: UniversalImage.imageProvider(
track.album!.images.asUrlString(
placeholder: ImagePlaceholder.albumArt,
),
),
fit: BoxFit.cover,
colorFilter: ColorFilter.mode(
colorScheme.background.withOpacity(0.5),
BlendMode.srcOver,
),
alignment: Alignment.topCenter,
),
fit: BoxFit.cover,
colorFilter: ColorFilter.mode(
colorScheme.background.withOpacity(0.5),
BlendMode.srcOver,
),
alignment: Alignment.topCenter,
),
),
),
),
Positioned.fill(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Skeletonizer(
enabled: trackQuery.isLoading,
child: Container(
alignment: Alignment.topCenter,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
colorScheme.background,
Colors.transparent,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.2, 1],
Positioned.fill(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Skeletonizer(
enabled: trackQuery.isLoading,
child: Container(
alignment: Alignment.topCenter,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
colorScheme.background,
Colors.transparent,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.2, 1],
),
),
),
child: SafeArea(
child: Wrap(
spacing: 20,
runSpacing: 20,
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
runAlignment: WrapAlignment.center,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: UniversalImage(
path: track.album!.images.asUrlString(
placeholder: ImagePlaceholder.albumArt,
child: SafeArea(
child: Wrap(
spacing: 20,
runSpacing: 20,
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
runAlignment: WrapAlignment.center,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: UniversalImage(
path: track.album!.images.asUrlString(
placeholder: ImagePlaceholder.albumArt,
),
height: 200,
width: 200,
),
height: 200,
width: 200,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: mediaQuery.smAndDown
? CrossAxisAlignment.center
: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
track.name!,
).large().semiBold(),
const Gap(10),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(SpotubeIcons.album),
const Gap(5),
Flexible(
child: LinkText(
track.album!.name!,
AlbumRoute(
id: track.album!.id!,
album: track.album!,
),
push: true,
),
),
],
),
const Gap(10),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(SpotubeIcons.artist),
const Gap(5),
Flexible(
child: ArtistLink(
artists: track.artists!,
hideOverflowArtist: false,
),
),
],
),
const Gap(10),
ConstrainedBox(
constraints:
const BoxConstraints(maxWidth: 350),
child: Row(
mainAxisSize: mediaQuery.smAndDown
? MainAxisSize.max
: MainAxisSize.min,
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: mediaQuery.smAndDown
? CrossAxisAlignment.center
: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
track.name!,
).large().semiBold(),
const Gap(10),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(SpotubeIcons.album),
const Gap(5),
if (!isActive &&
!playlist.tracks
.containsBy(track, (t) => t.id))
Button.outline(
leading:
const Icon(SpotubeIcons.queueAdd),
child: Text(context.l10n.queue),
onPressed: () {
playlistNotifier.addTrack(track);
},
),
const Gap(5),
if (!isActive &&
!playlist.tracks
.containsBy(track, (t) => t.id))
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.play_next),
Flexible(
child: LinkText(
track.album!.name!,
AlbumRoute(
id: track.album!.id!,
album: track.album!,
),
child: IconButton.outline(
icon: const Icon(
SpotubeIcons.lightning),
onPressed: () {
playlistNotifier
.addTracksAtFirst([track]);
},
),
),
const Gap(5),
Tooltip(
tooltip: TooltipContainer(
child: Text(
isActive
? context.l10n.pause_playback
: context.l10n.play,
),
),
child: IconButton.primary(
shape: ButtonShape.circle,
icon: Icon(
isActive
? SpotubeIcons.pause
: SpotubeIcons.play,
),
onPressed: onPlay,
push: true,
),
),
const Gap(5),
if (mediaQuery.smAndDown)
const Spacer()
else
const Gap(20),
TrackHeartButton(track: track),
TrackOptions(
track: track,
userPlaylist: false,
),
const Gap(5),
],
),
),
],
const Gap(10),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(SpotubeIcons.artist),
const Gap(5),
Flexible(
child: ArtistLink(
artists: track.artists!,
hideOverflowArtist: false,
),
),
],
),
const Gap(10),
ConstrainedBox(
constraints:
const BoxConstraints(maxWidth: 350),
child: Row(
mainAxisSize: mediaQuery.smAndDown
? MainAxisSize.max
: MainAxisSize.min,
children: [
const Gap(5),
if (!isActive &&
!playlist.tracks
.containsBy(track, (t) => t.id))
Button.outline(
leading:
const Icon(SpotubeIcons.queueAdd),
child: Text(context.l10n.queue),
onPressed: () {
playlistNotifier.addTrack(track);
},
),
const Gap(5),
if (!isActive &&
!playlist.tracks
.containsBy(track, (t) => t.id))
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.play_next),
),
child: IconButton.outline(
icon: const Icon(
SpotubeIcons.lightning),
onPressed: () {
playlistNotifier
.addTracksAtFirst([track]);
},
),
),
const Gap(5),
Tooltip(
tooltip: TooltipContainer(
child: Text(
isActive
? context.l10n.pause_playback
: context.l10n.play,
),
),
child: IconButton.primary(
shape: ButtonShape.circle,
icon: Icon(
isActive
? SpotubeIcons.pause
: SpotubeIcons.play,
),
onPressed: onPlay,
),
),
const Gap(5),
if (mediaQuery.smAndDown)
const Spacer()
else
const Gap(20),
TrackHeartButton(track: track),
TrackOptions(
track: track,
userPlaylist: false,
),
const Gap(5),
],
),
),
],
),
),
),
],
],
),
),
),
),
),
),
),
],
],
),
),
);
}