refactor: user local library

This commit is contained in:
Kingkor Roy Tirtho 2025-01-05 11:54:50 +06:00
parent 4afe0cca68
commit b8f2495acb
5 changed files with 441 additions and 439 deletions

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/context.dart';
@ -39,11 +39,8 @@ class ExpandableSearchField extends StatelessWidget {
child: TextField(
focusNode: searchFocus,
controller: searchController,
decoration: InputDecoration(
hintText: context.l10n.search_tracks,
isDense: true,
prefixIcon: const Icon(SpotubeIcons.search),
),
placeholder: Text(context.l10n.search_tracks),
leading: const Icon(SpotubeIcons.search),
),
),
),
@ -69,16 +66,9 @@ class ExpandableSearchButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return IconButton(
icon: icon,
style: IconButton.styleFrom(
backgroundColor:
isFiltering ? theme.colorScheme.secondaryContainer : null,
foregroundColor: isFiltering ? theme.colorScheme.secondary : null,
minimumSize: const Size(25, 25),
),
variance: isFiltering ? ButtonVariance.secondary : ButtonVariance.outline,
onPressed: () {
if (isFiltering) {
searchFocus.requestFocus();

View File

@ -215,7 +215,7 @@ class Spotube extends HookConsumerWidget {
theme: ThemeData(
radius: .5,
iconTheme: const IconThemeProperties(),
colorScheme: ColorSchemes.lightBlue(),
colorScheme: ColorSchemes.lightOrange(),
surfaceOpacity: .8,
surfaceBlur: 10,
),

View File

@ -1,18 +1,17 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:path/path.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/extensions/string.dart';
import 'package:spotube/hooks/utils/use_brightness_value.dart';
import 'package:spotube/pages/library/local_folder.dart';
import 'package:spotube/provider/local_tracks/local_tracks_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
@ -26,8 +25,6 @@ class LocalFolderItem extends HookConsumerWidget {
final ThemeData(:colorScheme) = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final lerpValue = useBrightnessValue(.9, .7);
final downloadFolder =
ref.watch(userPreferencesProvider.select((s) => s.downloadLocation));
final cacheFolder = useFuture(UserPreferencesNotifier.getMusicCacheDir());
@ -60,8 +57,8 @@ class LocalFolderItem extends HookConsumerWidget {
final tracks = trackSnapshot.value ?? [];
return InkWell(
onTap: () {
return Button(
onPressed: () {
context.goNamed(
LocalLibraryPage.name,
queryParameters: {
@ -71,18 +68,11 @@ class LocalFolderItem extends HookConsumerWidget {
extra: folder,
);
},
borderRadius: BorderRadius.circular(8),
child: Ink(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Color.lerp(
colorScheme.surfaceContainerHighest,
colorScheme.surface,
lerpValue,
style: ButtonVariance.card.copyWith(
padding: (context, states, value) {
return const EdgeInsets.all(8);
},
),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@ -123,6 +113,9 @@ class LocalFolderItem extends HookConsumerWidget {
),
const Gap(8),
Stack(
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
Center(
child: Text(
@ -133,25 +126,47 @@ class LocalFolderItem extends HookConsumerWidget {
: basename(folder),
style: const TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (!isDownloadFolder)
Wrap(
spacing: 2,
runSpacing: 2,
children: [
for (final MapEntry(key: index, value: segment)
in segments.asMap().entries)
Text.rich(
TextSpan(
children: [
if (index != 0) const TextSpan(text: "/ "),
TextSpan(text: segment),
],
),
maxLines: 2,
).xSmall().muted(),
],
),
],
),
if (!isDownloadFolder && !isCacheFolder)
Align(
alignment: Alignment.topRight,
child: PopupMenuButton(
child: const Padding(
padding: EdgeInsets.all(3),
child: Icon(Icons.more_vert),
),
itemBuilder: (context) {
return [
PopupMenuItem(
child: ListTile(
leading: const Icon(SpotubeIcons.folderRemove),
iconColor: colorScheme.error,
title:
child: IconButton.ghost(
icon: const Icon(Icons.more_vert),
size: ButtonSize.small,
onPressed: () {
showDropdown(
context: context,
builder: (context) {
return DropdownMenu(
children: [
MenuButton(
leading: Icon(SpotubeIcons.folderRemove,
color: colorScheme.destructive),
child:
Text(context.l10n.remove_library_location),
onTap: () {
onPressed: (context) {
final libraryLocations = ref
.read(userPreferencesProvider)
.localLibraryLocation;
@ -163,44 +178,19 @@ class LocalFolderItem extends HookConsumerWidget {
.toList(),
);
},
),
)
];
],
);
},
);
},
),
),
],
),
const Spacer(),
Wrap(
spacing: 2,
runSpacing: 2,
children: [
for (final MapEntry(key: index, value: segment)
in segments.asMap().entries)
Text.rich(
TextSpan(
children: [
if (index != 0)
TextSpan(
text: "/ ",
style: TextStyle(color: colorScheme.primary),
),
TextSpan(text: segment),
],
),
style: TextStyle(
fontSize: 10,
color: colorScheme.tertiary,
),
),
],
),
const Spacer(),
],
),
),
),
);
}
}

View File

@ -1,9 +1,8 @@
import 'package:file_picker/file_picker.dart';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/modules/library/local_folder/local_folder_item.dart';
@ -58,17 +57,23 @@ class UserLocalTracks extends HookConsumerWidget {
// For now, this gets all of them.
ref.watch(localTracksProvider);
return LayoutBuilder(builder: (context, constrains) {
return Padding(
final locations = [
preferences.downloadLocation,
if (cacheDir.hasData) cacheDir.data!,
...preferences.localLibraryLocation,
];
return LayoutBuilder(
builder: (context, constrains) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Column(
children: [
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
icon: const Icon(SpotubeIcons.folderAdd),
label: Text(context.l10n.add_library_location),
child: Button.secondary(
leading: const Icon(SpotubeIcons.folderAdd),
onPressed: addLocalLibraryLocation,
child: Text(context.l10n.add_library_location),
),
),
const Gap(8),
@ -84,23 +89,16 @@ class UserLocalTracks extends HookConsumerWidget {
crossAxisSpacing: 10,
mainAxisSpacing: 10,
),
itemCount: preferences.localLibraryLocation.length +
1 +
(cacheDir.hasData ? 1 : 0),
itemCount: locations.length,
itemBuilder: (context, index) {
return LocalFolderItem(
folder: index == 0
? preferences.downloadLocation
: index == 1 && cacheDir.hasData
? cacheDir.data!
: preferences.localLibraryLocation[index - 1],
folder: locations[index],
);
},
),
),
],
),
);
});
));
}
}

View File

@ -3,13 +3,16 @@ import 'dart:math';
import 'package:collection/collection.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/button/back_button.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/string.dart';
import 'package:spotube/modules/library/local_folder/cache_export_dialog.dart';
import 'package:spotube/modules/library/user_local_tracks.dart';
@ -65,7 +68,7 @@ class LocalLibraryPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final ThemeData(:textTheme) = Theme.of(context);
final scale = context.theme.scaling;
final sortBy = useState<SortBy>(SortBy.none);
final playlist = ref.watch(audioPlayerProvider);
@ -93,7 +96,13 @@ class LocalLibraryPage extends HookConsumerWidget {
return SafeArea(
bottom: false,
child: Scaffold(
appBar: TitleBar(
headers: [
TitleBar(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 0,
),
surfaceBlur: 0,
leading: const [BackButton()],
title: Column(
mainAxisSize: MainAxisSize.min,
@ -105,47 +114,43 @@ class LocalLibraryPage extends HookConsumerWidget {
: isCache
? context.l10n.cache_folder.capitalize()
: location,
style: textTheme.titleLarge,
),
FutureBuilder<String>(
future: directorySize,
builder: (context, snapshot) {
return Text(
"${(snapshot.data ?? 0)} GB",
style: textTheme.labelSmall,
);
).xSmall().muted();
},
)
],
),
backgroundColor: Colors.transparent,
trailingGap: 10,
trailing: [
if (isCache) ...[
IconButton(
iconSize: 16,
IconButton.outline(
size: ButtonSize.small,
icon: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(SpotubeIcons.delete),
Text(
context.l10n.clear_cache,
style: textTheme.labelSmall,
)
Text(context.l10n.clear_cache)
],
),
).xSmall().iconSmall(),
onPressed: () async {
final accepted = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog.adaptive(
builder: (context) => AlertDialog(
title: Text(context.l10n.clear_cache_confirmation),
actions: [
TextButton(
Button.outline(
onPressed: () {
Navigator.of(context).pop(false);
},
child: Text(context.l10n.decline),
),
TextButton(
Button.destructive(
onPressed: () async {
Navigator.of(context).pop(true);
},
@ -166,18 +171,17 @@ class LocalLibraryPage extends HookConsumerWidget {
}
},
),
IconButton(
iconSize: 16,
IconButton.outline(
size: ButtonSize.small,
icon: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(SpotubeIcons.export),
Text(
context.l10n.export,
style: textTheme.labelSmall,
)
],
),
).xSmall().iconSmall(),
onPressed: () async {
final exportPath =
await FilePicker.platform.getDirectoryPath();
@ -207,54 +211,69 @@ class LocalLibraryPage extends HookConsumerWidget {
]
],
),
body: Column(
],
child: LayoutBuilder(
builder: (context, constraints) => Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
const SizedBox(width: 5),
FilledButton(
const Gap(5),
Button.primary(
onPressed: trackSnapshot.asData?.value != null
? () async {
if (trackSnapshot.asData?.value.isNotEmpty ==
if (trackSnapshot
.asData?.value.isNotEmpty ==
true) {
if (!isPlaylistPlaying) {
await playLocalTracks(
ref,
trackSnapshot.asData!.value[location] ?? [],
trackSnapshot
.asData!.value[location] ??
[],
);
}
}
}
: null,
child: Row(
children: [
Text(context.l10n.play),
Icon(
leading: Icon(
isPlaylistPlaying
? SpotubeIcons.stop
: SpotubeIcons.play,
)
],
),
child: Text(context.l10n.play),
),
const Spacer(),
if (constraints.smAndDown)
ExpandableSearchButton(
isFiltering: isFiltering.value,
onPressed: (value) => isFiltering.value = value,
searchFocus: searchFocus,
)
else
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 300 * scale,
maxHeight: 38 * scale,
),
const SizedBox(width: 10),
child: ExpandableSearchField(
isFiltering: true,
onChangeFiltering: (value) {},
searchController: searchController,
searchFocus: searchFocus,
),
),
const Gap(5),
SortTracksDropdown(
value: sortBy.value,
onChanged: (value) {
sortBy.value = value;
},
),
const SizedBox(width: 5),
FilledButton(
child: const Icon(SpotubeIcons.refresh),
const Gap(5),
IconButton.outline(
icon: const Icon(SpotubeIcons.refresh),
onPressed: () {
ref.invalidate(localTracksProvider);
},
@ -268,11 +287,13 @@ class LocalLibraryPage extends HookConsumerWidget {
isFiltering: isFiltering.value,
onChangeFiltering: (value) => isFiltering.value = value,
),
trackSnapshot.when(
HookBuilder(builder: (context) {
return trackSnapshot.when(
data: (tracks) {
final sortedTracks = useMemoized(() {
return ServiceUtils.sortTracks(
tracks[location] ?? <LocalTrack>[], sortBy.value);
tracks[location] ?? <LocalTrack>[],
sortBy.value);
}, [sortBy.value, tracks]);
final filteredTracks = useMemoized(() {
@ -297,7 +318,8 @@ class LocalLibraryPage extends HookConsumerWidget {
.toList();
}, [searchController.text, sortedTracks]);
if (!trackSnapshot.isLoading && filteredTracks.isEmpty) {
if (!trackSnapshot.isLoading &&
filteredTracks.isEmpty) {
return const Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
@ -307,9 +329,9 @@ class LocalLibraryPage extends HookConsumerWidget {
}
return Expanded(
child: RefreshIndicator(
child: RefreshTrigger(
onRefresh: () async {
ref.invalidate(localTracksProvider);
// ref.invalidate(localTracksProvider);
},
child: InterScrollbar(
controller: controller,
@ -317,7 +339,8 @@ class LocalLibraryPage extends HookConsumerWidget {
enabled: trackSnapshot.isLoading,
child: ListView.builder(
controller: controller,
physics: const AlwaysScrollableScrollPhysics(),
physics:
const AlwaysScrollableScrollPhysics(),
itemCount: trackSnapshot.isLoading
? 5
: filteredTracks.length,
@ -366,9 +389,10 @@ class LocalLibraryPage extends HookConsumerWidget {
),
error: (error, stackTrace) =>
Text(error.toString() + stackTrace.toString()),
)
);
})
],
)),
))),
);
}
}