20 Commits

Author SHA1 Message Date
austinried
ad6d534286 context menu base and move query to state 2026-01-02 10:27:41 +09:00
austinried
2837d4576e logging framework 2025-12-14 10:09:32 +09:00
austinried
7f6ba4776a source settings (add/edit) 2025-12-10 20:21:43 +09:00
austinried
f7874bcead remove home tab for now 2025-12-07 13:21:32 +09:00
austinried
ba169092fd artist screen tweaks 2025-12-07 13:21:21 +09:00
austinried
4183e2d3b9 round album art corners in tiles 2025-12-07 11:26:48 +09:00
austinried
c3bb14edbf fix temp db seed 2025-12-07 11:26:33 +09:00
austinried
805e6fff7a artist screen 2025-12-07 11:26:21 +09:00
austinried
d245fc7fef fix song list source change not refreshing 2025-12-06 18:46:26 +09:00
austinried
3fcb938f2b playlists screen 2025-12-06 16:38:38 +09:00
austinried
97ea3c3230 devtools 2025-12-06 15:45:03 +09:00
austinried
71132a1f0e fix extra rebuild due to hierarchy change 2025-12-06 15:44:51 +09:00
austinried
f3969dc6af playlists list 2025-12-06 15:12:53 +09:00
austinried
a4e4c6fa57 songs list tab 2025-12-06 09:42:53 +09:00
austinried
16a79c81cb songs list and serializable list query 2025-12-05 21:16:48 +09:00
austinried
6609671ae2 cover art color scheme extraction (in background)
refactor text styles to use theme
port over part of album screen
2025-12-03 13:22:14 +09:00
austinried
b9a094c1c4 remove active sourceId subquery 2025-11-23 12:40:05 +09:00
austinried
fd800b0e12 flutter 3.38 upgrade 2025-11-23 11:41:20 +09:00
austinried
b6153ce3b6 refresh lists on source change and sync 2025-11-23 10:59:01 +09:00
austinried
798a907cca active source switching and reactivity 2025-11-22 17:00:14 +09:00
48 changed files with 6350 additions and 1200 deletions

26
.vscode/launch.json vendored
View File

@@ -1,26 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "debug",
"request": "launch",
"type": "dart",
"flutterMode": "debug"
},
{
"name": "profile mode",
"request": "launch",
"type": "dart",
"flutterMode": "profile"
},
{
"name": "release mode",
"request": "launch",
"type": "dart",
"flutterMode": "release"
}
]
}

3
devtools_options.yaml Normal file
View File

@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@@ -0,0 +1,22 @@
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../state/services.dart';
import '../state/source.dart';
void useOnSourceChange(WidgetRef ref, void Function(int sourceId) callback) {
final sourceId = ref.watch(sourceIdProvider);
useEffect(() {
callback(sourceId);
return;
}, [sourceId]);
}
void useOnSourceSync(WidgetRef ref, void Function() callback) {
final syncService = ref.watch(syncServiceProvider);
useOnListenableChange(syncService, () {
callback();
});
}

View File

@@ -1,81 +0,0 @@
import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import '../../sources/models.dart';
import '../hooks/use_paging_controller.dart';
import '../state/database.dart';
import '../state/source.dart';
import 'list_items.dart';
const kPageSize = 30;
typedef _ArtistItem = ({Artist artist, int? albumCount});
class ArtistsList extends HookConsumerWidget {
const ArtistsList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
final controller = usePagingController<int, _ArtistItem>(
getNextPageKey: (state) =>
state.lastPageIsEmpty ? null : state.nextIntPageKey,
fetchPage: (pageKey) async {
final albumCount = db.albums.id.count();
final query =
db.artists.select().join([
leftOuterJoin(
db.albums,
db.albums.artistId.equalsExp(db.artists.id),
),
])
..addColumns([albumCount])
..where(
db.artists.sourceId.equals(sourceId) &
db.albums.sourceId.equals(sourceId),
)
..groupBy([db.artists.sourceId, db.artists.id])
..orderBy([OrderingTerm.asc(db.artists.name)])
..limit(kPageSize, offset: (pageKey - 1) * kPageSize);
return (await query.get())
.map(
(row) => (
artist: row.readTable(db.artists),
albumCount: row.read(albumCount),
),
)
.toList();
},
);
return PagingListener(
controller: controller,
builder: (context, state, fetchNextPage) {
return PagedSliverList(
state: state,
fetchNextPage: fetchNextPage,
builderDelegate: PagedChildBuilderDelegate<_ArtistItem>(
itemBuilder: (context, item, index) {
final (:artist, :albumCount) = item;
return ArtistListTile(
artist: artist,
albumCount: albumCount,
onTap: () async {
context.push('/artist/${artist.id}');
},
);
},
),
);
},
);
}
}

View File

@@ -1,92 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../images/images.dart';
import '../../sources/models.dart';
import '../util/clip.dart';
class AlbumGridTile extends HookConsumerWidget {
const AlbumGridTile({
super.key,
required this.album,
this.onTap,
});
final Album album;
final void Function()? onTap;
@override
Widget build(BuildContext context, WidgetRef ref) {
return CardTheme(
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadiusGeometry.circular(3),
),
margin: EdgeInsets.all(2),
child: ImageCard(
onTap: onTap,
child: CoverArtImage(coverArt: album.coverArt),
),
);
}
}
class ArtistListTile extends StatelessWidget {
const ArtistListTile({
super.key,
required this.artist,
this.albumCount,
this.onTap,
});
final Artist artist;
final int? albumCount;
final void Function()? onTap;
@override
Widget build(BuildContext context) {
return ListTile(
leading: CircleClip(
child: CoverArtImage(coverArt: artist.coverArt),
),
title: Text(artist.name),
subtitle: albumCount != null ? Text('$albumCount albums') : null,
onTap: onTap,
);
}
}
class ImageCard extends StatelessWidget {
const ImageCard({
super.key,
required this.child,
this.onTap,
this.onLongPress,
});
final Widget child;
final void Function()? onTap;
final void Function()? onLongPress;
@override
Widget build(BuildContext context) {
return Card(
child: Stack(
fit: StackFit.passthrough,
alignment: Alignment.center,
children: [
child,
Positioned.fill(
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
),
),
),
],
),
);
}
}

View File

@@ -4,9 +4,11 @@ import 'screens/album_screen.dart';
import 'screens/artist_screen.dart';
import 'screens/library_screen.dart';
import 'screens/now_playing_screen.dart';
import 'screens/playlist_screen.dart';
import 'screens/preload_screen.dart';
import 'screens/root_shell_screen.dart';
import 'screens/settings_screen.dart';
import 'screens/settings_source_screen.dart';
final router = GoRouter(
initialLocation: '/preload',
@@ -23,13 +25,19 @@ final router = GoRouter(
builder: (context, state) => LibraryScreen(),
routes: [
GoRoute(
path: 'album/:id',
path: 'albums/:id',
builder: (context, state) =>
AlbumScreen(id: state.pathParameters['id']!),
),
GoRoute(
path: 'artist',
builder: (context, state) => ArtistScreen(),
path: 'artists/:id',
builder: (context, state) =>
ArtistScreen(id: state.pathParameters['id']!),
),
GoRoute(
path: 'playlists/:id',
builder: (context, state) =>
PlaylistScreen(id: state.pathParameters['id']!),
),
],
),
@@ -43,5 +51,12 @@ final router = GoRouter(
path: '/settings',
builder: (context, state) => SettingsScreen(),
),
GoRoute(
path: '/sources/:id',
builder: (context, state) {
final id = state.pathParameters['id'];
return SettingsSourceScreen(id: id == 'add' ? null : int.parse(id!));
},
),
],
);

View File

@@ -1,8 +1,19 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class AlbumScreen extends StatelessWidget {
import '../../database/query.dart';
import '../../l10n/generated/app_localizations.dart';
import '../state/database.dart';
import '../state/source.dart';
import '../ui/cover_art_theme.dart';
import '../ui/gradient.dart';
import '../ui/lists/header.dart';
import '../ui/lists/items.dart';
import '../ui/lists/songs_list.dart';
class AlbumScreen extends HookConsumerWidget {
const AlbumScreen({
super.key,
required this.id,
@@ -11,23 +22,52 @@ class AlbumScreen extends StatelessWidget {
final String id;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Album $id!'),
TextButton(
onPressed: () {
context.push('/artist');
},
child: Text('Artist...'),
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
final getAlbum = useMemoized(
() => db.libraryDao.getAlbum(sourceId, id).getSingle(),
);
final album = useFuture(getAlbum).data;
if (album == null) {
return Container();
}
final query = SongsQuery(
sourceId: sourceId,
filter: IList([SongsFilter.albumId(album.id)]),
sort: IList([
SortingTerm.songsAsc(SongsColumn.disc),
SortingTerm.songsAsc(SongsColumn.track),
SortingTerm.songsAsc(SongsColumn.title),
]),
);
return CoverArtTheme(
coverArt: album.coverArt,
child: Scaffold(
body: GradientScrollView(
slivers: [
SliverToBoxAdapter(
child: SongsListHeader(
title: album.name,
subtitle: album.albumArtist,
coverArt: album.coverArt,
playText: l.resourcesAlbumActionsPlay,
onPlay: () {},
onMore: () {},
),
),
SongsList(
query: query,
itemBuilder: (context, item, index) => SongListTile(
song: item.song,
onTap: () {},
),
CachedNetworkImage(
imageUrl: 'https://placehold.net/400x400.png',
placeholder: (context, url) => CircularProgressIndicator(),
errorWidget: (context, url, error) => Icon(Icons.error),
),
],
),

View File

@@ -1,12 +1,124 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class ArtistScreen extends StatelessWidget {
const ArtistScreen({super.key});
import '../../database/query.dart';
import '../../sources/models.dart';
import '../state/database.dart';
import '../state/source.dart';
import '../ui/cover_art_theme.dart';
import '../ui/gradient.dart';
import '../ui/images.dart';
import '../ui/lists/albums_list.dart';
class ArtistScreen extends HookConsumerWidget {
const ArtistScreen({
super.key,
required this.id,
});
final String id;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(child: Text('Artist!')),
Widget build(BuildContext context, WidgetRef ref) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
final getArtist = useMemoized(
() => db.libraryDao.getArtist(sourceId, id).getSingle(),
);
final artist = useFuture(getArtist).data;
if (artist == null) {
return Container();
}
final query = AlbumsQuery(
sourceId: sourceId,
filter: IList([AlbumsFilter.artistId(artist.id)]),
sort: IList([
SortingTerm.albumsDesc(AlbumsColumn.year),
SortingTerm.albumsAsc(AlbumsColumn.name),
]),
);
return CoverArtTheme(
coverArt: artist.coverArt,
child: Scaffold(
body: GradientScrollView(
slivers: [
ArtistHeader(artist: artist),
AlbumsList(query: query),
],
),
),
);
}
}
class ArtistHeader extends StatelessWidget {
const ArtistHeader({
super.key,
required this.artist,
});
final Artist artist;
@override
Widget build(BuildContext context) {
final textTheme = TextTheme.of(context);
final colorScheme = ColorScheme.of(context);
return SliverToBoxAdapter(
child: Stack(
fit: StackFit.passthrough,
alignment: AlignmentGeometry.bottomCenter,
children: [
CoverArtImage(
fit: BoxFit.cover,
coverArt: artist.coverArt,
thumbnail: false,
height: 350,
),
Column(
children: [
Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: AlignmentGeometry.centerRight,
end: AlignmentGeometry.centerLeft,
colors: [
colorScheme.surface.withAlpha(220),
colorScheme.surface.withAlpha(150),
colorScheme.surface.withAlpha(20),
],
),
),
child: Padding(
padding: EdgeInsetsGeometry.symmetric(
vertical: 12,
horizontal: 16,
),
child: Text(
artist.name,
textAlign: TextAlign.right,
style: textTheme.headlineLarge?.copyWith(
shadows: [
Shadow(
blurRadius: 20,
color: colorScheme.surface,
),
],
),
),
),
),
],
),
],
),
);
}
}

View File

@@ -5,17 +5,21 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../l10n/generated/app_localizations.dart';
import '../lists/albums_grid.dart';
import '../lists/artists_list.dart';
import '../state/lists.dart';
import '../state/services.dart';
import '../ui/text.dart';
import '../ui/lists/albums_grid.dart';
import '../ui/lists/artists_list.dart';
import '../ui/lists/items.dart';
import '../ui/lists/playlists_list.dart';
import '../ui/lists/songs_list.dart';
import '../ui/menus.dart';
import '../util/custom_scroll_fix.dart';
const kIconSize = 26.0;
const kTabHeight = 36.0;
enum LibraryTab {
home(Icon(Symbols.home_rounded)),
// home(Icon(Symbols.home_rounded)),
albums(Icon(Symbols.album_rounded)),
artists(Icon(Symbols.person_rounded)),
songs(Icon(Symbols.music_note_rounded)),
@@ -36,7 +40,7 @@ class LibraryScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final tabController = useTabController(
initialLength: LibraryTab.values.length,
initialIndex: 1,
initialIndex: 0,
);
return Scaffold(
@@ -52,7 +56,27 @@ class LibraryScreen extends HookConsumerWidget {
builder: (context) => CustomScrollProvider(
tabController: tabController,
parent: PrimaryScrollController.of(context),
child: TabBarView(
child: LibraryTabBarView(tabController: tabController),
),
),
),
);
}
}
class LibraryTabBarView extends HookConsumerWidget {
const LibraryTabBarView({
super.key,
required this.tabController,
});
final TabController tabController;
@override
Widget build(BuildContext context, WidgetRef ref) {
final songsQuery = ref.watch(songsQueryProvider);
return TabBarView(
controller: tabController,
children: LibraryTab.values
.map(
@@ -60,15 +84,29 @@ class LibraryScreen extends HookConsumerWidget {
index: LibraryTab.values.indexOf(tab),
sliver: switch (tab) {
LibraryTab.albums => AlbumsGrid(),
_ => ArtistsList(),
LibraryTab.artists => ArtistsList(),
LibraryTab.playlists => PlaylistsList(),
LibraryTab.songs => SongsList(
query: songsQuery,
itemBuilder: (context, item, index) => SongListTile(
song: item.song,
coverArt: item.albumCoverArt,
showLeading: true,
onTap: () {},
),
),
// _ => SliverToBoxAdapter(child: Container()),
},
menuBuilder: switch (tab) {
LibraryTab.albums => (_) => AlbumsGridFilters(),
// LibraryTab.artists => (_) => AlbumsGridFilters(),
// LibraryTab.playlists => (_) => AlbumsGridFilters(),
// LibraryTab.songs => (_) => AlbumsGridFilters(),
_ => null,
},
),
)
.toList(),
),
),
),
),
);
}
}
@@ -162,10 +200,11 @@ class TabTitleText extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
final text = TextTheme.of(context);
String tabLocalization(LibraryTab tab) => switch (tab) {
LibraryTab.albums => l.navigationTabsAlbums,
LibraryTab.home => l.navigationTabsHome,
// LibraryTab.home => l.navigationTabsHome,
LibraryTab.artists => l.navigationTabsArtists,
LibraryTab.songs => l.navigationTabsSongs,
LibraryTab.playlists => l.navigationTabsPlaylists,
@@ -180,7 +219,7 @@ class TabTitleText extends HookConsumerWidget {
return;
}, [tabName]);
return TextH1(tabText.value);
return Text(tabText.value, style: text.headlineLarge);
}
}
@@ -189,10 +228,12 @@ class TabScrollView extends HookConsumerWidget {
super.key,
required this.index,
required this.sliver,
this.menuBuilder,
});
final int index;
final Widget sliver;
final WidgetBuilder? menuBuilder;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -200,7 +241,16 @@ class TabScrollView extends HookConsumerWidget {
final scrollProvider = CustomScrollProviderData.of(context);
return CustomScrollView(
final listBuilder = menuBuilder;
final floatingActionButton = listBuilder != null
? FabFilter(
listBuilder: listBuilder,
)
: null;
return Scaffold(
floatingActionButton: floatingActionButton,
body: CustomScrollView(
controller: scrollProvider.scrollControllers[index],
slivers: <Widget>[
SliverOverlapInjector(
@@ -208,6 +258,7 @@ class TabScrollView extends HookConsumerWidget {
),
sliver,
],
),
);
}
}

View File

@@ -0,0 +1,77 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../database/query.dart';
import '../../l10n/generated/app_localizations.dart';
import '../state/database.dart';
import '../state/source.dart';
import '../ui/cover_art_theme.dart';
import '../ui/gradient.dart';
import '../ui/lists/header.dart';
import '../ui/lists/items.dart';
import '../ui/lists/songs_list.dart';
class PlaylistScreen extends HookConsumerWidget {
const PlaylistScreen({
super.key,
required this.id,
});
final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
final getPlaylist = useMemoized(
() => db.libraryDao.getPlaylist(sourceId, id).getSingle(),
);
final playlist = useFuture(getPlaylist).data;
if (playlist == null) {
return Container();
}
final query = SongsQuery(
sourceId: sourceId,
filter: IList([SongsFilter.playlistId(playlist.id)]),
sort: IList([
SortingTerm.songsAsc(SongsColumn.playlistPosition),
]),
);
return CoverArtTheme(
coverArt: playlist.coverArt,
child: Scaffold(
body: GradientScrollView(
slivers: [
SliverToBoxAdapter(
child: SongsListHeader(
title: playlist.name,
// subtitle: playlist.albumArtist,
coverArt: playlist.coverArt,
playText: l.resourcesPlaylistActionsPlay,
onPlay: () {},
onMore: () {},
),
),
SongsList(
query: query,
itemBuilder: (context, item, index) => SongListTile(
song: item.song,
coverArt: item.albumCoverArt,
showLeading: true,
onTap: () {},
),
),
],
),
),
);
}
}

View File

@@ -1,9 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../l10n/generated/app_localizations.dart';
import '../state/database.dart';
import '../state/source.dart';
const kHorizontalPadding = 16.0;
const kHorizontalPadding = 18.0;
class SettingsScreen extends HookConsumerWidget {
const SettingsScreen({super.key});
@@ -11,13 +15,16 @@ class SettingsScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
final textTheme = TextTheme.of(context);
return Scaffold(
appBar: AppBar(
title: Text(l.navigationTabsSettings, style: textTheme.headlineLarge),
),
body: ListView(
children: [
const SizedBox(height: 96),
_SectionHeader(l.settingsServersName),
// const _Sources(),
const _Sources(),
// _SectionHeader(l.settingsNetworkName),
// const _Network(),
// _SectionHeader(l.settingsAboutName),
@@ -29,9 +36,11 @@ class SettingsScreen extends HookConsumerWidget {
}
class _Section extends StatelessWidget {
final List<Widget> children;
const _Section({
required this.children,
});
const _Section({required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
@@ -39,35 +48,30 @@ class _Section extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
...children,
const SizedBox(height: 32),
],
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader(
this.title,
);
const _SectionHeader(this.title);
final String title;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final text = TextTheme.of(context);
return Column(
children: [
SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: kHorizontalPadding),
child: Text(
title,
style: theme.textTheme.displaySmall,
return Padding(
padding: EdgeInsetsGeometry.directional(
start: kHorizontalPadding,
end: kHorizontalPadding,
top: 32,
bottom: 8,
),
),
),
const SizedBox(height: 12),
],
child: Text(title, style: text.headlineMedium),
);
}
}
@@ -346,79 +350,66 @@ class _SectionHeader extends StatelessWidget {
// }
// }
// class _Sources extends HookConsumerWidget {
// const _Sources();
class _Sources extends HookConsumerWidget {
const _Sources();
// @override
// Widget build(BuildContext context, WidgetRef ref) {
// final sources = ref.watch(
// settingsServiceProvider.select(
// (value) => value.sources,
// ),
// );
// final activeSource = ref.watch(
// settingsServiceProvider.select(
// (value) => value.activeSource,
// ),
// );
@override
Widget build(BuildContext context, WidgetRef ref) {
final db = ref.watch(databaseProvider);
final activeSourceId = ref.watch(sourceIdProvider);
final sources = useStream(db.sourcesDao.listSources()).data;
// final l = AppLocalizations.of(context);
final l = AppLocalizations.of(context);
// return _Section(
// children: [
// for (var source in sources)
// RadioListTile<int>(
// value: source.id,
// groupValue: activeSource?.id,
// onChanged: (value) {
// ref
// .read(settingsServiceProvider.notifier)
// .setActiveSource(source.id);
// },
// title: Text(source.name),
// subtitle: Text(
// source.address.toString(),
// maxLines: 1,
// softWrap: false,
// overflow: TextOverflow.fade,
// ),
// secondary: IconButton(
// icon: const Icon(Icons.edit_rounded),
// onPressed: () {
// context.pushRoute(SourceRoute(id: source.id));
// },
// ),
// ),
// const SizedBox(height: 8),
// Row(
// mainAxisAlignment: MainAxisAlignment.center,
// children: [
// OutlinedButton.icon(
// icon: const Icon(Icons.add_rounded),
// label: Text(l.settingsServersActionsAdd),
// onPressed: () {
// context.pushRoute(SourceRoute());
// },
// ),
// ],
// ),
// // TODO: remove
// if (kDebugMode)
// Row(
// mainAxisAlignment: MainAxisAlignment.center,
// children: [
// OutlinedButton.icon(
// icon: const Icon(Icons.add_rounded),
// label: const Text('Add TEST'),
// onPressed: () {
// ref
// .read(settingsServiceProvider.notifier)
// .addTestSource('TEST');
// },
// ),
// ],
// ),
// ],
// );
// }
// }
if (sources == null) {
return Container();
}
return _Section(
children: [
RadioGroup<int>(
groupValue: activeSourceId,
onChanged: (value) {
if (value != null) {
db.sourcesDao.setActiveSource(value);
}
},
child: Column(
children: [
for (final (:source, :subsonicSetting) in sources)
RadioListTile<int>(
value: source.id,
title: Text(source.name),
subtitle: Text(
subsonicSetting.address.toString(),
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
),
secondary: IconButton(
icon: const Icon(Icons.edit_rounded),
onPressed: () {
context.push('/sources/${source.id}');
},
),
),
],
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton.icon(
icon: const Icon(Icons.add_rounded),
label: Text(l.settingsServersActionsAdd),
onPressed: () {
context.push('/sources/add');
},
),
],
),
],
);
}
}

View File

@@ -0,0 +1,309 @@
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../database/dao/sources_dao.dart';
import '../../database/database.dart';
import '../../l10n/generated/app_localizations.dart';
import '../../util/logger.dart';
import '../state/database.dart';
import '../ui/menus.dart';
class SettingsSourceScreen extends HookConsumerWidget {
const SettingsSourceScreen({
super.key,
this.id,
});
final int? id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final db = ref.watch(databaseProvider);
final getSource = useMemoized(
() async => id != null
? (result: await db.sourcesDao.getSource(id!).getSingle())
: await Future.value((result: null)),
[id],
);
final sourceResult = useFuture(getSource).data;
if (sourceResult == null) {
return Scaffold();
}
return _SettingsSourceScreen(source: sourceResult.result);
}
}
class _SettingsSourceScreen extends HookConsumerWidget {
const _SettingsSourceScreen({
required this.source,
});
final SourceSetting? source;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
final textTheme = TextTheme.of(context);
final colorScheme = ColorScheme.of(context);
final form = useState(GlobalKey<FormState>()).value;
final isSaving = useState(false);
final isDeleting = useState(false);
final nameController = useTextEditingController(text: source?.source.name);
final addressController = useTextEditingController(
text: source?.subsonicSetting.address.toString(),
);
final usernameController = useTextEditingController(
text: source?.subsonicSetting.username,
);
final passwordController = useTextEditingController(
text: source?.subsonicSetting.password,
);
final forcePlaintextPassword = useState(
!(source?.subsonicSetting.useTokenAuth ?? true),
);
final name = LabeledTextField(
label: l.settingsServersFieldsName,
controller: nameController,
required: true,
);
final address = LabeledTextField(
label: l.settingsServersFieldsAddress,
controller: addressController,
keyboardType: TextInputType.url,
autofillHints: const [AutofillHints.url],
required: true,
validator: (value, label) {
final uri = Uri.tryParse(value ?? '');
if (uri?.isAbsolute != true || uri?.host.isNotEmpty != true) {
return '$label must be a valid URL';
}
return null;
},
);
final username = LabeledTextField(
label: l.settingsServersFieldsUsername,
controller: usernameController,
autofillHints: const [AutofillHints.username],
required: true,
);
final password = LabeledTextField(
label: l.settingsServersFieldsPassword,
controller: passwordController,
autofillHints: const [AutofillHints.password],
obscureText: true,
required: true,
);
final forcePlaintextSwitch = SwitchListTile(
value: forcePlaintextPassword.value,
title: Text(l.settingsServersOptionsForcePlaintextPasswordTitle),
subtitle: forcePlaintextPassword.value
? Text(l.settingsServersOptionsForcePlaintextPasswordDescriptionOn)
: Text(l.settingsServersOptionsForcePlaintextPasswordDescriptionOff),
onChanged: (value) => forcePlaintextPassword.value = value,
);
return PopScope(
canPop: !isSaving.value && !isDeleting.value,
child: Scaffold(
appBar: AppBar(
title: Text(
source == null
? l.settingsServersActionsAdd
: l.settingsServersActionsEdit,
style: textTheme.headlineLarge,
),
),
floatingActionButton: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (source != null && source?.source.isActive != true)
FloatingActionButton(
backgroundColor: colorScheme.tertiaryContainer,
foregroundColor: colorScheme.onTertiaryContainer,
onPressed: !isSaving.value && !isDeleting.value
? () async {
try {
isDeleting.value = true;
await ref
.read(databaseProvider)
.sourcesDao
.deleteSource(source!.source.id);
} finally {
isDeleting.value = false;
}
if (context.mounted) {
context.pop();
}
}
: null,
child: isDeleting.value
? SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
color: colorScheme.onTertiaryContainer,
),
)
: const Icon(Icons.delete_forever_rounded),
),
const SizedBox(width: 12),
FloatingActionButton.extended(
heroTag: null,
icon: isSaving.value
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(),
)
: const Icon(Icons.save_rounded),
label: Text(l.settingsServersActionsSave),
onPressed: !isSaving.value && !isDeleting.value
? () async {
if (!form.currentState!.validate()) {
return;
}
var error = false;
try {
isSaving.value = true;
if (source != null) {
await ref
.read(databaseProvider)
.sourcesDao
.updateSource(
source!.source.copyWith(
name: nameController.text,
),
source!.subsonicSetting.copyWith(
address: Uri.parse(addressController.text),
username: usernameController.text,
password: passwordController.text,
useTokenAuth: !forcePlaintextPassword.value,
),
);
} else {
await ref
.read(databaseProvider)
.sourcesDao
.createSource(
SourcesCompanion.insert(
name: nameController.text,
),
SubsonicSettingsCompanion.insert(
address: Uri.parse(addressController.text),
username: usernameController.text,
password: passwordController.text,
useTokenAuth: Value(
!forcePlaintextPassword.value,
),
),
);
}
} catch (e, _) {
// showErrorSnackbar(context, e.toString());
// log.severe('Saving source', e, st);
logger.w('fuck');
error = true;
} finally {
isSaving.value = false;
}
if (!error && context.mounted) {
context.pop();
}
}
: null,
),
],
),
body: Form(
key: form,
child: AutofillGroup(
child: ListView(
children: [
name,
address,
username,
password,
const SizedBox(height: 24),
forcePlaintextSwitch,
const FabPadding(),
],
),
),
),
),
);
}
}
class LabeledTextField extends HookConsumerWidget {
const LabeledTextField({
super.key,
required this.label,
required this.controller,
this.obscureText = false,
this.keyboardType,
this.validator,
this.autofillHints,
this.required = false,
});
final String label;
final TextEditingController controller;
final bool obscureText;
final bool required;
final TextInputType? keyboardType;
final Iterable<String>? autofillHints;
final String? Function(String? value, String label)? validator;
@override
Widget build(BuildContext context, WidgetRef ref) {
final textTheme = TextTheme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 32),
Text(label, style: textTheme.titleMedium),
TextFormField(
controller: controller,
obscureText: obscureText,
keyboardType: keyboardType,
autofillHints: autofillHints,
validator: (value) {
if (required) {
if (value == null || value.isEmpty) {
return '$label is required';
}
}
if (validator != null) {
return validator!(value, label);
}
return null;
},
),
],
),
);
}
}

View File

@@ -1,4 +1,4 @@
import 'package:drift/drift.dart' show Value;
import 'package:drift/drift.dart' show InsertMode, Value;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../database/database.dart';
@@ -6,29 +6,40 @@ import '../../database/database.dart';
final databaseInitializer = FutureProvider<SubtracksDatabase>((ref) async {
final db = SubtracksDatabase();
await db
.into(db.sources)
.insertOnConflictUpdate(
await db.batch((batch) {
batch.insertAll(
db.sources,
[
SourcesCompanion.insert(
id: Value(1),
name: 'test navidrome',
name: 'test subsonic',
isActive: Value(true),
),
SourcesCompanion.insert(
id: Value(2),
name: 'test navidrome',
isActive: Value(null),
),
],
mode: InsertMode.insertOrIgnore,
);
await db
.into(db.subsonicSettings)
.insertOnConflictUpdate(
batch.insertAllOnConflictUpdate(db.subsonicSettings, [
SubsonicSettingsCompanion.insert(
sourceId: Value(1),
address: Uri.parse('http://demo.subsonic.org'),
username: 'guest1',
password: 'guest',
// address: Uri.parse('http://10.0.2.2:4533'),
// username: 'admin',
// password: 'password',
useTokenAuth: Value(true),
),
);
SubsonicSettingsCompanion.insert(
sourceId: Value(2),
address: Uri.parse('http://10.0.2.2:4533'),
username: 'admin',
password: 'password',
useTokenAuth: Value(true),
),
]);
});
return db;
});

31
lib/app/state/lists.dart Normal file
View File

@@ -0,0 +1,31 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../database/query.dart';
import 'source.dart';
final albumsQueryProvider = Provider<AlbumsQuery>((ref) {
final sourceId = ref.watch(sourceIdProvider);
return AlbumsQuery(
sourceId: sourceId,
sort: IList([
SortingTerm.albumsDesc(AlbumsColumn.created),
]),
);
});
final songsQueryProvider = Provider<SongsQuery>((ref) {
final sourceId = ref.watch(sourceIdProvider);
return SongsQuery(
sourceId: sourceId,
sort: IList([
SortingTerm.songsAsc(SongsColumn.albumArtist),
SortingTerm.songsAsc(SongsColumn.album),
SortingTerm.songsAsc(SongsColumn.disc),
SortingTerm.songsAsc(SongsColumn.track),
SortingTerm.songsAsc(SongsColumn.title),
]),
);
});

View File

@@ -10,17 +10,15 @@ final activeSourceInitializer = StreamProvider<(int, SubsonicSource)>((
) async* {
final db = ref.watch(databaseProvider);
final activeSource = db.managers.sources
.filter((f) => f.isActive.equals(true))
.watchSingle();
final activeSource = db.sourcesDao.activeSourceId().watchSingle();
await for (final source in activeSource) {
await for (final sourceId in activeSource) {
final subsonicSettings = await db.managers.subsonicSettings
.filter((f) => f.sourceId.equals(source.id))
.filter((f) => f.sourceId.equals(sourceId))
.getSingle();
yield (
source.id,
sourceId!,
SubsonicSource(
SubsonicClient(
http: SubtracksHttpClient(),

View File

@@ -0,0 +1,61 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../util/logger.dart';
import '../state/source.dart';
import '../util/color_scheme.dart';
import 'theme.dart';
class CoverArtTheme extends HookConsumerWidget {
const CoverArtTheme({
super.key,
required this.coverArt,
required this.child,
});
final String? coverArt;
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
final source = ref.watch(sourceProvider);
final sourceId = ref.watch(sourceIdProvider);
final getColorScheme = useMemoized(
() async {
try {
return await colorSchemefromImageProvider(
brightness: Brightness.dark,
provider: CachedNetworkImageProvider(
coverArt != null
? source.coverArtUri(coverArt!, thumbnail: true).toString()
: 'https://placehold.net/400x400.png',
cacheKey: coverArt != null
? '$sourceId$coverArt${true}'
: 'https://placehold.net/400x400.png',
),
);
} catch (error, stackTrace) {
logger.w(
'Could not create color scheme from image provider',
error: error,
stackTrace: stackTrace,
);
return null;
}
},
[source, sourceId, coverArt],
);
final colorScheme = useFuture(getColorScheme).data;
return Theme(
data: colorScheme == null
? Theme.of(context)
: subtracksTheme(colorScheme),
child: child,
);
}
}

63
lib/app/ui/gradient.dart Normal file
View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sliver_tools/sliver_tools.dart';
class ThemedGradient extends LinearGradient {
const ThemedGradient({
required super.colors,
super.begin,
super.end,
});
factory ThemedGradient.of(
BuildContext context, {
AlignmentGeometry begin = Alignment.topCenter,
AlignmentGeometry end = Alignment.bottomCenter,
}) {
final colorScheme = Theme.of(context).colorScheme;
return ThemedGradient(
begin: begin,
end: end,
colors: [
colorScheme.primaryContainer,
colorScheme.surface,
],
);
}
}
class GradientScrollView extends HookConsumerWidget {
const GradientScrollView({
super.key,
required this.slivers,
});
final List<Widget> slivers;
@override
Widget build(BuildContext context, WidgetRef ref) {
return CustomScrollView(
slivers: [
SliverStack(
children: [
SliverPositioned.directional(
textDirection: TextDirection.ltr,
start: 0,
end: 0,
top: 0,
child: Ink(
width: double.infinity,
height: MediaQuery.heightOf(context),
decoration: BoxDecoration(
gradient: ThemedGradient.of(context),
),
),
),
MultiSliver(children: slivers),
],
),
],
);
}
}

View File

@@ -3,18 +3,23 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../app/state/settings.dart';
import '../app/state/source.dart';
import '../state/source.dart';
class CoverArtImage extends HookConsumerWidget {
const CoverArtImage({
super.key,
this.coverArt,
this.thumbnail = false,
this.thumbnail = true,
this.fit = BoxFit.cover,
this.height,
this.width,
});
final String? coverArt;
final bool thumbnail;
final BoxFit fit;
final double? height;
final double? width;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -25,34 +30,14 @@ class CoverArtImage extends HookConsumerWidget {
? source.coverArtUri(coverArt!, thumbnail: thumbnail).toString()
: 'https://placehold.net/400x400.png';
return BaseImage(
imageUrl: imageUrl,
// can't use the URL because of token auth, which is a cache-buster
cacheKey: '$sourceId$coverArt$thumbnail',
);
}
}
class BaseImage extends HookConsumerWidget {
const BaseImage({
super.key,
required this.imageUrl,
this.cacheKey,
this.fit = BoxFit.cover,
});
final String imageUrl;
final String? cacheKey;
final BoxFit fit;
@override
Widget build(BuildContext context, WidgetRef ref) {
return CachedNetworkImage(
height: height,
width: width,
imageUrl: imageUrl,
cacheKey: cacheKey,
cacheKey: '$sourceId$coverArt$thumbnail',
placeholder: (context, url) => Icon(Symbols.cached_rounded),
errorWidget: (context, url, error) => Icon(Icons.error),
fit: BoxFit.cover,
fit: fit,
fadeOutDuration: Duration(milliseconds: 100),
fadeInDuration: Duration(milliseconds: 200),
);

View File

@@ -1,14 +1,17 @@
import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import '../../sources/models.dart';
import '../hooks/use_paging_controller.dart';
import '../state/database.dart';
import '../state/source.dart';
import 'list_items.dart';
import '../../../sources/models.dart';
import '../../hooks/use_on_source.dart';
import '../../hooks/use_paging_controller.dart';
import '../../state/database.dart';
import '../../state/lists.dart';
import '../../state/source.dart';
import '../menus.dart';
import 'items.dart';
const kPageSize = 60;
@@ -18,20 +21,23 @@ class AlbumsGrid extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
final query = ref.watch(albumsQueryProvider);
final controller = usePagingController<int, Album>(
getNextPageKey: (state) =>
state.lastPageIsEmpty ? null : state.nextIntPageKey,
fetchPage: (pageKey) async {
final query = db.albums.select()
..where((f) => f.sourceId.equals(sourceId))
..limit(kPageSize, offset: (pageKey - 1) * kPageSize);
return await query.get();
},
fetchPage: (pageKey) => db.libraryDao.listAlbums(
query.copyWith(
sourceId: ref.read(sourceIdProvider),
limit: kPageSize,
offset: (pageKey - 1) * kPageSize,
),
),
);
useOnSourceSync(ref, controller.refresh);
useValueChanged(query, (_, _) => controller.refresh());
return PagingListener(
controller: controller,
builder: (context, state, fetchNextPage) {
@@ -43,11 +49,13 @@ class AlbumsGrid extends HookConsumerWidget {
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
),
showNoMoreItemsIndicatorAsGridChild: false,
builderDelegate: PagedChildBuilderDelegate<Album>(
noMoreItemsIndicatorBuilder: (context) => FabPadding(),
itemBuilder: (context, item, index) => AlbumGridTile(
album: item,
onTap: () async {
context.push('/album/${item.id}');
context.push('/albums/${item.id}');
},
),
),
@@ -57,3 +65,14 @@ class AlbumsGrid extends HookConsumerWidget {
);
}
}
class AlbumsGridFilters extends HookConsumerWidget {
const AlbumsGridFilters({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView(
children: [],
);
}
}

View File

@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import '../../../database/query.dart';
import '../../../sources/models.dart';
import '../../hooks/use_on_source.dart';
import '../../hooks/use_paging_controller.dart';
import '../../state/database.dart';
import '../../state/source.dart';
import '../menus.dart';
import 'items.dart';
const kPageSize = 30;
class AlbumsList extends HookConsumerWidget {
const AlbumsList({
super.key,
required this.query,
});
final AlbumsQuery query;
@override
Widget build(BuildContext context, WidgetRef ref) {
final db = ref.watch(databaseProvider);
final controller = usePagingController<int, Album>(
getNextPageKey: (state) =>
state.lastPageIsEmpty ? null : state.nextIntPageKey,
fetchPage: (pageKey) => db.libraryDao.listAlbums(
query.copyWith(
sourceId: ref.read(sourceIdProvider),
limit: kPageSize,
offset: (pageKey - 1) * kPageSize,
),
),
);
useOnSourceChange(ref, (_) => controller.refresh());
useOnSourceSync(ref, controller.refresh);
return PagingListener(
controller: controller,
builder: (context, state, fetchNextPage) {
return PagedSliverList(
state: state,
fetchNextPage: fetchNextPage,
builderDelegate: PagedChildBuilderDelegate<Album>(
noMoreItemsIndicatorBuilder: (context) => FabPadding(),
itemBuilder: (context, item, index) {
final tile = AlbumListTile(
album: item,
onTap: () {
context.push('/albums/${item.id}');
},
);
final currentItemYear = item.year;
final previousItemYear = index == 0
? currentItemYear
: controller.items?.elementAtOrNull(index - 1)?.year;
if (index == 0 || currentItemYear != previousItemYear) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(
top: 24,
bottom: 8,
left: 16,
right: 16,
),
child: Text(
item.year?.toString() ?? 'Unknown year',
style: TextTheme.of(context).headlineMedium,
),
),
tile,
],
);
}
return tile;
},
),
);
},
);
}
}

View File

@@ -0,0 +1,66 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import '../../../database/dao/library_dao.dart';
import '../../../database/query.dart';
import '../../hooks/use_on_source.dart';
import '../../hooks/use_paging_controller.dart';
import '../../state/database.dart';
import '../../state/source.dart';
import '../menus.dart';
import 'items.dart';
const kPageSize = 30;
class ArtistsList extends HookConsumerWidget {
const ArtistsList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final db = ref.watch(databaseProvider);
final controller = usePagingController<int, AristListItem>(
getNextPageKey: (state) =>
state.lastPageIsEmpty ? null : state.nextIntPageKey,
fetchPage: (pageKey) => db.libraryDao.listArtists(
ArtistsQuery(
sourceId: ref.read(sourceIdProvider),
sort: IList([
SortingTerm.artistsAsc(ArtistsColumn.name),
]),
limit: kPageSize,
offset: (pageKey - 1) * kPageSize,
),
),
);
useOnSourceChange(ref, (_) => controller.refresh());
useOnSourceSync(ref, controller.refresh);
return PagingListener(
controller: controller,
builder: (context, state, fetchNextPage) {
return PagedSliverList(
state: state,
fetchNextPage: fetchNextPage,
builderDelegate: PagedChildBuilderDelegate<AristListItem>(
noMoreItemsIndicatorBuilder: (context) => FabPadding(),
itemBuilder: (context, item, index) {
final (:artist, :albumCount) = item;
return ArtistListTile(
artist: artist,
albumCount: albumCount,
onTap: () async {
context.push('/artists/${artist.id}');
},
);
},
),
);
},
);
}
}

View File

@@ -0,0 +1,105 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../images.dart';
class SongsListHeader extends HookConsumerWidget {
const SongsListHeader({
super.key,
required this.title,
this.subtitle,
this.coverArt,
this.playText,
this.onPlay,
this.onMore,
// required this.downloadActions,
});
final String title;
final String? subtitle;
final String? coverArt;
final String? playText;
final void Function()? onPlay;
final FutureOr<void> Function()? onMore;
// final List<DownloadAction> downloadActions;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return SafeArea(
minimum: EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 24),
Container(
height: 300,
width: 300,
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
blurRadius: 20,
blurStyle: BlurStyle.normal,
color: Colors.black.withAlpha(100),
offset: Offset.zero,
spreadRadius: 2,
),
],
),
child: CoverArtImage(
thumbnail: false,
coverArt: coverArt,
fit: BoxFit.contain,
),
),
const SizedBox(height: 20),
Column(
children: [
Text(
title,
style: theme.textTheme.headlineMedium,
textAlign: TextAlign.center,
),
if (subtitle != null)
Text(
subtitle!,
style: theme.textTheme.headlineSmall,
textAlign: TextAlign.center,
),
],
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
IconButton(
onPressed: () {},
icon: const Icon(Icons.download_done_rounded),
),
if (onPlay != null)
FilledButton.icon(
onPressed: onPlay,
icon: const Icon(Icons.play_arrow_rounded),
label: Text(
playText ?? '',
// style: theme.textTheme.bodyLarge?.copyWith(
// color: theme.colorScheme.onPrimary,
// ),
),
),
if (onMore != null)
IconButton(
onPressed: onMore,
icon: const Icon(Icons.more_horiz),
),
],
),
const SizedBox(height: 24),
],
),
);
}
}

215
lib/app/ui/lists/items.dart Normal file
View File

@@ -0,0 +1,215 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../sources/models.dart';
import '../../util/clip.dart';
import '../images.dart';
class AlbumGridTile extends HookConsumerWidget {
const AlbumGridTile({
super.key,
required this.album,
this.onTap,
});
final Album album;
final void Function()? onTap;
@override
Widget build(BuildContext context, WidgetRef ref) {
return CardTheme(
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadiusGeometry.circular(3),
),
margin: EdgeInsets.all(2),
child: ImageCard(
onTap: onTap,
child: CoverArtImage(
coverArt: album.coverArt,
thumbnail: true,
),
),
);
}
}
class ArtistListTile extends StatelessWidget {
const ArtistListTile({
super.key,
required this.artist,
required this.albumCount,
this.onTap,
});
final Artist artist;
final int albumCount;
final void Function()? onTap;
@override
Widget build(BuildContext context) {
return ListTile(
leading: CircleClip(
child: CoverArtImage(
coverArt: artist.coverArt,
thumbnail: true,
),
),
title: Text(artist.name),
subtitle: Text('$albumCount albums'),
onTap: onTap,
);
}
}
class AlbumListTile extends StatelessWidget {
const AlbumListTile({
super.key,
required this.album,
this.onTap,
});
final Album album;
final void Function()? onTap;
@override
Widget build(BuildContext context) {
final textTheme = TextTheme.of(context);
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(left: 8, right: 18),
child: RoundedBoxClip(
child: CoverArtImage(
coverArt: album.coverArt,
thumbnail: true,
width: 80,
height: 80,
),
),
),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
album.name,
style: textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
Text(
album.albumArtist ?? 'Unknown album artist',
style: textTheme.bodyMedium,
),
],
),
),
],
),
),
);
}
}
class PlaylistListTile extends StatelessWidget {
const PlaylistListTile({
super.key,
required this.playlist,
this.albumCount,
this.onTap,
});
final Playlist playlist;
final int? albumCount;
final void Function()? onTap;
@override
Widget build(BuildContext context) {
return ListTile(
leading: RoundedBoxClip(
child: CoverArtImage(
coverArt: playlist.coverArt,
thumbnail: true,
),
),
title: Text(playlist.name),
subtitle: Text(playlist.comment ?? ''),
onTap: onTap,
);
}
}
class SongListTile extends StatelessWidget {
const SongListTile({
super.key,
required this.song,
this.coverArt,
this.showLeading = false,
this.onTap,
});
final Song song;
final String? coverArt;
final bool showLeading;
final void Function()? onTap;
@override
Widget build(BuildContext context) {
return ListTile(
leading: showLeading
? RoundedBoxClip(
child: CoverArtImage(
coverArt: coverArt,
thumbnail: true,
),
)
: null,
title: Text(song.title),
subtitle: Text(song.artist ?? ''),
onTap: onTap,
);
}
}
class ImageCard extends StatelessWidget {
const ImageCard({
super.key,
required this.child,
this.onTap,
this.onLongPress,
});
final Widget child;
final void Function()? onTap;
final void Function()? onLongPress;
@override
Widget build(BuildContext context) {
return Card(
child: Stack(
fit: StackFit.passthrough,
alignment: Alignment.center,
children: [
child,
Positioned.fill(
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import '../../../database/query.dart';
import '../../../sources/models.dart';
import '../../hooks/use_on_source.dart';
import '../../hooks/use_paging_controller.dart';
import '../../state/database.dart';
import '../../state/source.dart';
import '../menus.dart';
import 'items.dart';
const kPageSize = 30;
class PlaylistsList extends HookConsumerWidget {
const PlaylistsList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final db = ref.watch(databaseProvider);
final controller = usePagingController<int, Playlist>(
getNextPageKey: (state) =>
state.lastPageIsEmpty ? null : state.nextIntPageKey,
fetchPage: (pageKey) => db.libraryDao.listPlaylists(
PlaylistsQuery(
sourceId: ref.read(sourceIdProvider),
sort: IList([
SortingTerm.playlistsDesc(PlaylistsColumn.created),
]),
limit: kPageSize,
offset: (pageKey - 1) * kPageSize,
),
),
);
useOnSourceChange(ref, (_) => controller.refresh());
useOnSourceSync(ref, controller.refresh);
return PagingListener(
controller: controller,
builder: (context, state, fetchNextPage) {
return PagedSliverList(
state: state,
fetchNextPage: fetchNextPage,
builderDelegate: PagedChildBuilderDelegate<Playlist>(
noMoreItemsIndicatorBuilder: (context) => FabPadding(),
itemBuilder: (context, item, index) {
return PlaylistListTile(
playlist: item,
onTap: () {
context.push('/playlists/${item.id}');
},
);
},
),
);
},
);
}
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import '../../../database/dao/library_dao.dart';
import '../../../database/query.dart';
import '../../hooks/use_on_source.dart';
import '../../hooks/use_paging_controller.dart';
import '../../state/database.dart';
import '../../state/source.dart';
import '../menus.dart';
const kPageSize = 30;
class SongsList extends HookConsumerWidget {
const SongsList({
super.key,
required this.query,
required this.itemBuilder,
});
final SongsQuery query;
final Widget Function(BuildContext context, SongListItem item, int index)
itemBuilder;
@override
Widget build(BuildContext context, WidgetRef ref) {
final db = ref.watch(databaseProvider);
final controller = usePagingController<int, SongListItem>(
getNextPageKey: (state) =>
state.lastPageIsEmpty ? null : state.nextIntPageKey,
fetchPage: (pageKey) => db.libraryDao.listSongs(
query.copyWith(
sourceId: ref.read(sourceIdProvider),
limit: kPageSize,
offset: (pageKey - 1) * kPageSize,
),
),
);
useOnSourceSync(ref, controller.refresh);
useValueChanged(query, (_, _) => controller.refresh());
return PagingListener(
controller: controller,
builder: (context, state, fetchNextPage) {
return PagedSliverList(
state: state,
fetchNextPage: fetchNextPage,
builderDelegate: PagedChildBuilderDelegate<SongListItem>(
noMoreItemsIndicatorBuilder: (context) => FabPadding(),
itemBuilder: itemBuilder,
),
);
},
);
}
}

76
lib/app/ui/menus.dart Normal file
View File

@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
Future<void> showContextMenu({
required BuildContext context,
required WidgetBuilder listBuilder,
}) => showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => DraggableScrollableSheet(
expand: false,
snap: true,
initialChildSize: 0.3,
minChildSize: 0.3,
maxChildSize: 0.4,
builder: (context, scrollController) => PrimaryScrollController(
controller: scrollController,
child: listBuilder(context),
),
),
);
class ContextMenuList extends StatelessWidget {
const ContextMenuList({
super.key,
required this.children,
});
final List<Widget> children;
@override
Widget build(BuildContext context) {
return ListView(
children: children,
);
}
}
class FabFilter extends StatelessWidget {
const FabFilter({
super.key,
required this.listBuilder,
});
final WidgetBuilder listBuilder;
@override
Widget build(BuildContext context) {
return FloatingActionButton(
onPressed: () {
showContextMenu(
context: context,
listBuilder: listBuilder,
);
},
child: Icon(
Symbols.filter_list_rounded,
weight: 500,
opticalSize: 28,
size: 28,
),
);
}
}
class FabPadding extends StatelessWidget {
const FabPadding({
super.key,
});
@override
Widget build(BuildContext context) {
return const SizedBox(height: 86);
}
}

View File

@@ -1,43 +0,0 @@
import 'package:flutter/material.dart';
class TextH1 extends StatelessWidget {
const TextH1(
this.data, {
super.key,
});
final String data;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Text(
data,
style: theme.textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.w800,
),
);
}
}
class TextH2 extends StatelessWidget {
const TextH2(
this.data, {
super.key,
});
final String data;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Text(
data,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
),
);
}
}

29
lib/app/ui/theme.dart Normal file
View File

@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
ThemeData subtracksTheme([ColorScheme? colorScheme]) {
final theme = ThemeData.from(
colorScheme:
colorScheme ??
ColorScheme.fromSeed(
seedColor: Colors.purple.shade800,
brightness: Brightness.dark,
),
useMaterial3: true,
);
final text = theme.textTheme.copyWith(
headlineLarge: theme.textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.w800,
),
headlineMedium: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
),
headlineSmall: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
),
);
return theme.copyWith(
textTheme: text,
);
}

View File

@@ -19,3 +19,21 @@ class CircleClip extends StatelessWidget {
);
}
}
class RoundedBoxClip extends StatelessWidget {
const RoundedBoxClip({
super.key,
required this.child,
});
final Widget child;
@override
Widget build(BuildContext context) {
return ClipRRect(
clipBehavior: Clip.antiAlias,
borderRadius: BorderRadiusGeometry.circular(3),
child: child,
);
}
}

View File

@@ -0,0 +1,431 @@
/// This file is a fork of the built-in [ColorScheme.fromImageProvider] function
/// with the following changes:
///
/// 1. [_extractColorsFromImageProvider] now runs the Quantizer to extract colors
/// in an isolate to prevent jank on the UI thread (especially noticable during
/// transitions).
///
/// 2. [_imageProviderToScaled] has its hard-coded image resize max dimensions
/// to 32x32 pixels.
library;
import 'dart:async';
import 'dart:isolate';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:material_color_utilities/material_color_utilities.dart';
/// Generate a [ColorScheme] derived from the given `imageProvider`.
///
/// Material Color Utilities extracts the dominant color from the
/// supplied [ImageProvider]. Using this color, a [ColorScheme] is generated
/// with harmonious colors that meet contrast requirements for accessibility.
///
/// If any of the optional color parameters are non-null, they will be
/// used in place of the generated colors for that field in the resulting
/// [ColorScheme]. This allows apps to override specific colors for their
/// needs.
///
/// Given the nature of the algorithm, the most dominant color of the
/// `imageProvider` may not wind up as one of the [ColorScheme] colors.
///
/// The provided image will be scaled down to a maximum size of 112x112 pixels
/// during color extraction.
///
/// {@tool dartpad}
/// This sample shows how to use [ColorScheme.fromImageProvider] to create
/// content-based dynamic color schemes.
///
/// ** See code in examples/api/lib/material/color_scheme/dynamic_content_color.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [M3 Guidelines: Dynamic color from content](https://m3.material.io/styles/color/dynamic-color/user-generated-color#8af550b9-a19e-4e9f-bb0a-7f611fed5d0f)
/// * <https://pub.dev/packages/dynamic_color>, a package to create
/// [ColorScheme]s based on a platform's implementation of dynamic color.
/// * <https://m3.material.io/styles/color/the-color-system/color-roles>, the
/// Material 3 Color system specification.
/// * <https://pub.dev/packages/material_color_utilities>, the package
/// used to algorithmically determine the dominant color and to generate
/// the [ColorScheme].
Future<ColorScheme> colorSchemefromImageProvider({
required ImageProvider provider,
Brightness brightness = Brightness.light,
DynamicSchemeVariant dynamicSchemeVariant = DynamicSchemeVariant.tonalSpot,
double contrastLevel = 0.0,
Color? primary,
Color? onPrimary,
Color? primaryContainer,
Color? onPrimaryContainer,
Color? primaryFixed,
Color? primaryFixedDim,
Color? onPrimaryFixed,
Color? onPrimaryFixedVariant,
Color? secondary,
Color? onSecondary,
Color? secondaryContainer,
Color? onSecondaryContainer,
Color? secondaryFixed,
Color? secondaryFixedDim,
Color? onSecondaryFixed,
Color? onSecondaryFixedVariant,
Color? tertiary,
Color? onTertiary,
Color? tertiaryContainer,
Color? onTertiaryContainer,
Color? tertiaryFixed,
Color? tertiaryFixedDim,
Color? onTertiaryFixed,
Color? onTertiaryFixedVariant,
Color? error,
Color? onError,
Color? errorContainer,
Color? onErrorContainer,
Color? outline,
Color? outlineVariant,
Color? surface,
Color? onSurface,
Color? surfaceDim,
Color? surfaceBright,
Color? surfaceContainerLowest,
Color? surfaceContainerLow,
Color? surfaceContainer,
Color? surfaceContainerHigh,
Color? surfaceContainerHighest,
Color? onSurfaceVariant,
Color? inverseSurface,
Color? onInverseSurface,
Color? inversePrimary,
Color? shadow,
Color? scrim,
Color? surfaceTint,
@Deprecated(
'Use surface instead. '
'This feature was deprecated after v3.18.0-0.1.pre.',
)
Color? background,
@Deprecated(
'Use onSurface instead. '
'This feature was deprecated after v3.18.0-0.1.pre.',
)
Color? onBackground,
@Deprecated(
'Use surfaceContainerHighest instead. '
'This feature was deprecated after v3.18.0-0.1.pre.',
)
Color? surfaceVariant,
}) async {
// Extract dominant colors from image.
final QuantizerResult quantizerResult = await _extractColorsFromImageProvider(
provider,
);
final Map<int, int> colorToCount = quantizerResult.colorToCount.map(
(int key, int value) => MapEntry<int, int>(_getArgbFromAbgr(key), value),
);
// Score colors for color scheme suitability.
final List<int> scoredResults = Score.score(colorToCount, desired: 1);
final ui.Color baseColor = Color(scoredResults.first);
final DynamicScheme scheme = _buildDynamicScheme(
brightness,
baseColor,
dynamicSchemeVariant,
contrastLevel,
);
return ColorScheme(
primary: primary ?? Color(MaterialDynamicColors.primary.getArgb(scheme)),
onPrimary:
onPrimary ?? Color(MaterialDynamicColors.onPrimary.getArgb(scheme)),
primaryContainer:
primaryContainer ??
Color(MaterialDynamicColors.primaryContainer.getArgb(scheme)),
onPrimaryContainer:
onPrimaryContainer ??
Color(MaterialDynamicColors.onPrimaryContainer.getArgb(scheme)),
primaryFixed:
primaryFixed ??
Color(MaterialDynamicColors.primaryFixed.getArgb(scheme)),
primaryFixedDim:
primaryFixedDim ??
Color(MaterialDynamicColors.primaryFixedDim.getArgb(scheme)),
onPrimaryFixed:
onPrimaryFixed ??
Color(MaterialDynamicColors.onPrimaryFixed.getArgb(scheme)),
onPrimaryFixedVariant:
onPrimaryFixedVariant ??
Color(MaterialDynamicColors.onPrimaryFixedVariant.getArgb(scheme)),
secondary:
secondary ?? Color(MaterialDynamicColors.secondary.getArgb(scheme)),
onSecondary:
onSecondary ?? Color(MaterialDynamicColors.onSecondary.getArgb(scheme)),
secondaryContainer:
secondaryContainer ??
Color(MaterialDynamicColors.secondaryContainer.getArgb(scheme)),
onSecondaryContainer:
onSecondaryContainer ??
Color(MaterialDynamicColors.onSecondaryContainer.getArgb(scheme)),
secondaryFixed:
secondaryFixed ??
Color(MaterialDynamicColors.secondaryFixed.getArgb(scheme)),
secondaryFixedDim:
secondaryFixedDim ??
Color(MaterialDynamicColors.secondaryFixedDim.getArgb(scheme)),
onSecondaryFixed:
onSecondaryFixed ??
Color(MaterialDynamicColors.onSecondaryFixed.getArgb(scheme)),
onSecondaryFixedVariant:
onSecondaryFixedVariant ??
Color(MaterialDynamicColors.onSecondaryFixedVariant.getArgb(scheme)),
tertiary: tertiary ?? Color(MaterialDynamicColors.tertiary.getArgb(scheme)),
onTertiary:
onTertiary ?? Color(MaterialDynamicColors.onTertiary.getArgb(scheme)),
tertiaryContainer:
tertiaryContainer ??
Color(MaterialDynamicColors.tertiaryContainer.getArgb(scheme)),
onTertiaryContainer:
onTertiaryContainer ??
Color(MaterialDynamicColors.onTertiaryContainer.getArgb(scheme)),
tertiaryFixed:
tertiaryFixed ??
Color(MaterialDynamicColors.tertiaryFixed.getArgb(scheme)),
tertiaryFixedDim:
tertiaryFixedDim ??
Color(MaterialDynamicColors.tertiaryFixedDim.getArgb(scheme)),
onTertiaryFixed:
onTertiaryFixed ??
Color(MaterialDynamicColors.onTertiaryFixed.getArgb(scheme)),
onTertiaryFixedVariant:
onTertiaryFixedVariant ??
Color(MaterialDynamicColors.onTertiaryFixedVariant.getArgb(scheme)),
error: error ?? Color(MaterialDynamicColors.error.getArgb(scheme)),
onError: onError ?? Color(MaterialDynamicColors.onError.getArgb(scheme)),
errorContainer:
errorContainer ??
Color(MaterialDynamicColors.errorContainer.getArgb(scheme)),
onErrorContainer:
onErrorContainer ??
Color(MaterialDynamicColors.onErrorContainer.getArgb(scheme)),
outline: outline ?? Color(MaterialDynamicColors.outline.getArgb(scheme)),
outlineVariant:
outlineVariant ??
Color(MaterialDynamicColors.outlineVariant.getArgb(scheme)),
surface: surface ?? Color(MaterialDynamicColors.surface.getArgb(scheme)),
surfaceDim:
surfaceDim ?? Color(MaterialDynamicColors.surfaceDim.getArgb(scheme)),
surfaceBright:
surfaceBright ??
Color(MaterialDynamicColors.surfaceBright.getArgb(scheme)),
surfaceContainerLowest:
surfaceContainerLowest ??
Color(MaterialDynamicColors.surfaceContainerLowest.getArgb(scheme)),
surfaceContainerLow:
surfaceContainerLow ??
Color(MaterialDynamicColors.surfaceContainerLow.getArgb(scheme)),
surfaceContainer:
surfaceContainer ??
Color(MaterialDynamicColors.surfaceContainer.getArgb(scheme)),
surfaceContainerHigh:
surfaceContainerHigh ??
Color(MaterialDynamicColors.surfaceContainerHigh.getArgb(scheme)),
surfaceContainerHighest:
surfaceContainerHighest ??
Color(MaterialDynamicColors.surfaceContainerHighest.getArgb(scheme)),
onSurface:
onSurface ?? Color(MaterialDynamicColors.onSurface.getArgb(scheme)),
onSurfaceVariant:
onSurfaceVariant ??
Color(MaterialDynamicColors.onSurfaceVariant.getArgb(scheme)),
inverseSurface:
inverseSurface ??
Color(MaterialDynamicColors.inverseSurface.getArgb(scheme)),
onInverseSurface:
onInverseSurface ??
Color(MaterialDynamicColors.inverseOnSurface.getArgb(scheme)),
inversePrimary:
inversePrimary ??
Color(MaterialDynamicColors.inversePrimary.getArgb(scheme)),
shadow: shadow ?? Color(MaterialDynamicColors.shadow.getArgb(scheme)),
scrim: scrim ?? Color(MaterialDynamicColors.scrim.getArgb(scheme)),
surfaceTint:
surfaceTint ?? Color(MaterialDynamicColors.primary.getArgb(scheme)),
brightness: brightness,
// DEPRECATED (newest deprecations at the bottom)
// ignore: deprecated_member_use
background:
background ?? Color(MaterialDynamicColors.background.getArgb(scheme)),
// ignore: deprecated_member_use
onBackground:
onBackground ??
Color(MaterialDynamicColors.onBackground.getArgb(scheme)),
// ignore: deprecated_member_use
surfaceVariant:
surfaceVariant ??
Color(MaterialDynamicColors.surfaceVariant.getArgb(scheme)),
);
}
// ColorScheme.fromImageProvider() utilities.
/// Extracts bytes from an [ImageProvider] and returns a [QuantizerResult]
/// containing the most dominant colors.
Future<QuantizerResult> _extractColorsFromImageProvider(
ImageProvider imageProvider,
) async {
final ui.Image scaledImage = await _imageProviderToScaled(imageProvider);
final ByteData? imageBytes = await scaledImage.toByteData();
return Isolate.run(
() => QuantizerCelebi().quantize(
imageBytes!.buffer.asUint32List(),
128,
returnInputPixelToClusterPixel: true,
),
);
}
/// Scale image size down to reduce computation time of color extraction.
Future<ui.Image> _imageProviderToScaled(ImageProvider imageProvider) async {
const double maxDimension = 32.0;
final ImageStream stream = imageProvider.resolve(
const ImageConfiguration(size: Size(maxDimension, maxDimension)),
);
final Completer<ui.Image> imageCompleter = Completer<ui.Image>();
late ImageStreamListener listener;
late ui.Image scaledImage;
Timer? loadFailureTimeout;
listener = ImageStreamListener(
(ImageInfo info, bool sync) async {
loadFailureTimeout?.cancel();
stream.removeListener(listener);
final ui.Image image = info.image;
final int width = image.width;
final int height = image.height;
double paintWidth = width.toDouble();
double paintHeight = height.toDouble();
assert(width > 0 && height > 0);
final bool rescale = width > maxDimension || height > maxDimension;
if (rescale) {
paintWidth = (width > height)
? maxDimension
: (maxDimension / height) * width;
paintHeight = (height > width)
? maxDimension
: (maxDimension / width) * height;
}
final ui.PictureRecorder pictureRecorder = ui.PictureRecorder();
final Canvas canvas = Canvas(pictureRecorder);
paintImage(
canvas: canvas,
rect: Rect.fromLTRB(0, 0, paintWidth, paintHeight),
image: image,
filterQuality: FilterQuality.none,
);
final ui.Picture picture = pictureRecorder.endRecording();
scaledImage = await picture.toImage(
paintWidth.toInt(),
paintHeight.toInt(),
);
imageCompleter.complete(info.image);
},
onError: (Object exception, StackTrace? stackTrace) {
loadFailureTimeout?.cancel();
stream.removeListener(listener);
imageCompleter.completeError(
Exception('Failed to render image: $exception'),
stackTrace,
);
},
);
loadFailureTimeout = Timer(const Duration(seconds: 5), () {
stream.removeListener(listener);
imageCompleter.completeError(
TimeoutException('Timeout occurred trying to load image'),
);
});
stream.addListener(listener);
await imageCompleter.future;
return scaledImage;
}
/// Converts AABBGGRR color int to AARRGGBB format.
int _getArgbFromAbgr(int abgr) {
const int exceptRMask = 0xFF00FFFF;
const int onlyRMask = ~exceptRMask;
const int exceptBMask = 0xFFFFFF00;
const int onlyBMask = ~exceptBMask;
final int r = (abgr & onlyRMask) >> 16;
final int b = abgr & onlyBMask;
return (abgr & exceptRMask & exceptBMask) | (b << 16) | r;
}
DynamicScheme _buildDynamicScheme(
Brightness brightness,
Color seedColor,
DynamicSchemeVariant schemeVariant,
double contrastLevel,
) {
assert(
contrastLevel >= -1.0 && contrastLevel <= 1.0,
'contrastLevel must be between -1.0 and 1.0 inclusive.',
);
final bool isDark = brightness == Brightness.dark;
// ignore: deprecated_member_use
final Hct sourceColor = Hct.fromInt(seedColor.value);
return switch (schemeVariant) {
DynamicSchemeVariant.tonalSpot => SchemeTonalSpot(
sourceColorHct: sourceColor,
isDark: isDark,
contrastLevel: contrastLevel,
),
DynamicSchemeVariant.fidelity => SchemeFidelity(
sourceColorHct: sourceColor,
isDark: isDark,
contrastLevel: contrastLevel,
),
DynamicSchemeVariant.content => SchemeContent(
sourceColorHct: sourceColor,
isDark: isDark,
contrastLevel: contrastLevel,
),
DynamicSchemeVariant.monochrome => SchemeMonochrome(
sourceColorHct: sourceColor,
isDark: isDark,
contrastLevel: contrastLevel,
),
DynamicSchemeVariant.neutral => SchemeNeutral(
sourceColorHct: sourceColor,
isDark: isDark,
contrastLevel: contrastLevel,
),
DynamicSchemeVariant.vibrant => SchemeVibrant(
sourceColorHct: sourceColor,
isDark: isDark,
contrastLevel: contrastLevel,
),
DynamicSchemeVariant.expressive => SchemeExpressive(
sourceColorHct: sourceColor,
isDark: isDark,
contrastLevel: contrastLevel,
),
DynamicSchemeVariant.rainbow => SchemeRainbow(
sourceColorHct: sourceColor,
isDark: isDark,
contrastLevel: contrastLevel,
),
DynamicSchemeVariant.fruitSalad => SchemeFruitSalad(
sourceColorHct: sourceColor,
isDark: isDark,
contrastLevel: contrastLevel,
),
};
}

View File

@@ -0,0 +1,249 @@
import 'package:drift/drift.dart';
import '../../sources/models.dart' as models;
import '../database.dart';
import '../query.dart';
part 'library_dao.g.dart';
typedef AristListItem = ({
models.Artist artist,
int albumCount,
});
typedef SongListItem = ({
models.Song song,
String? albumCoverArt,
});
extension on SortDirection {
OrderingMode toMode() => switch (this) {
SortDirection.asc => OrderingMode.asc,
SortDirection.desc => OrderingMode.desc,
};
}
@DriftAccessor(include: {'../tables.drift'})
class LibraryDao extends DatabaseAccessor<SubtracksDatabase>
with _$LibraryDaoMixin {
LibraryDao(super.db);
Future<List<models.Album>> listAlbums(AlbumsQuery q) {
final query = albums.select()
..where((albums) {
var filter = albums.sourceId.equals(q.sourceId);
for (final queryFilter in q.filter) {
filter &= switch (queryFilter) {
AlbumsFilterArtistId(:final artistId) => albums.artistId.equals(
artistId,
),
AlbumsFilterYearEquals(:final year) => albums.year.equals(year),
_ => CustomExpression(''),
};
}
return filter;
})
..orderBy(
q.sort
.map(
(sort) =>
(albums) => OrderingTerm(
expression: switch (sort.by) {
AlbumsColumn.name => albums.name,
AlbumsColumn.created => albums.created,
AlbumsColumn.year => albums.year,
AlbumsColumn.starred => albums.starred,
},
mode: sort.dir.toMode(),
),
)
.toList(),
);
_limitQuery(query: query, limit: q.limit, offset: q.offset);
return query.get();
}
Future<List<AristListItem>> listArtists(ArtistsQuery q) {
final albumCount = albums.id.count();
var filter =
artists.sourceId.equals(q.sourceId) &
albums.sourceId.equals(q.sourceId);
for (final queryFilter in q.filter) {
filter &= switch (queryFilter) {
ArtistsFilterStarred(:final starred) =>
starred ? artists.starred.isNotNull() : artists.starred.isNull(),
ArtistsFilterNameSearch() => CustomExpression(''),
_ => CustomExpression(''),
};
}
final query =
artists.select().join([
leftOuterJoin(
albums,
albums.artistId.equalsExp(artists.id),
),
])
..addColumns([albumCount])
..where(filter)
..groupBy([artists.sourceId, artists.id])
..orderBy(
q.sort
.map(
(sort) => OrderingTerm(
expression: switch (sort.by) {
ArtistsColumn.name => artists.name,
ArtistsColumn.starred => artists.starred,
ArtistsColumn.albumCount => albumCount,
},
mode: sort.dir.toMode(),
),
)
.toList(),
);
_limitQuery(query: query, limit: q.limit, offset: q.offset);
return query
.map(
(row) => (
artist: row.readTable(artists),
albumCount: row.read(albumCount) ?? 0,
),
)
.get();
}
Future<List<SongListItem>> listSongs(SongsQuery q) {
var joinPlaylistSongs = false;
var filter = songs.sourceId.equals(q.sourceId);
for (final queryFilter in q.filter) {
switch (queryFilter) {
case SongsFilterAlbumId(:final albumId):
filter &= songs.albumId.equals(albumId);
case SongsFilterPlaylistId(:final playlistId):
joinPlaylistSongs = true;
filter &= playlistSongs.playlistId.equals(playlistId);
}
}
final query =
songs.select().join([
leftOuterJoin(
albums,
albums.id.equalsExp(songs.albumId) &
albums.sourceId.equals(q.sourceId),
),
if (joinPlaylistSongs)
leftOuterJoin(
playlistSongs,
playlistSongs.sourceId.equals(q.sourceId) &
playlistSongs.songId.equalsExp(songs.id),
),
])
..addColumns([
albums.coverArt,
])
..where(filter)
..orderBy(
q.sort
.map(
(sort) => OrderingTerm(
expression: switch (sort.by) {
SongsColumn.title => songs.title,
SongsColumn.starred => songs.starred,
SongsColumn.disc => songs.disc,
SongsColumn.track => songs.track,
SongsColumn.album => songs.album,
SongsColumn.artist => songs.artist,
SongsColumn.albumArtist => albums.albumArtist,
SongsColumn.playlistPosition => playlistSongs.position,
},
mode: sort.dir.toMode(),
),
)
.toList(),
);
_limitQuery(query: query, limit: q.limit, offset: q.offset);
return query
.map(
(row) => (
song: row.readTable(songs),
albumCoverArt: row.read(albums.coverArt),
),
)
.get();
}
Future<List<models.Playlist>> listPlaylists(PlaylistsQuery q) {
final query = playlists.select()
..where((playlists) {
var filter = playlists.sourceId.equals(q.sourceId);
for (final queryFilter in q.filter) {
filter &= switch (queryFilter) {
PlaylistsFilterPublic(:final public) => playlists.public.equals(
public,
),
PlaylistsFilterNameSearch() => CustomExpression(''),
_ => CustomExpression(''),
};
}
return filter;
})
..orderBy(
q.sort
.map(
(sort) =>
(albums) => OrderingTerm(
expression: switch (sort.by) {
PlaylistsColumn.name => playlists.name,
PlaylistsColumn.created => playlists.created,
},
mode: sort.dir.toMode(),
),
)
.toList(),
);
_limitQuery(query: query, limit: q.limit, offset: q.offset);
return query.get();
}
Selectable<models.Album> getAlbum(int sourceId, String id) {
return db.managers.albums.filter(
(f) => f.sourceId.equals(sourceId) & f.id.equals(id),
);
}
Selectable<models.Artist> getArtist(int sourceId, String id) {
return db.managers.artists.filter(
(f) => f.sourceId.equals(sourceId) & f.id.equals(id),
);
}
Selectable<models.Playlist> getPlaylist(int sourceId, String id) {
return db.managers.playlists.filter(
(f) => f.sourceId.equals(sourceId) & f.id.equals(id),
);
}
void _limitQuery({
required LimitContainerMixin query,
required int? limit,
required int? offset,
}) {
if (limit != null) {
query.limit(limit, offset: offset);
}
}
}

View File

@@ -0,0 +1,14 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'library_dao.dart';
// ignore_for_file: type=lint
mixin _$LibraryDaoMixin on DatabaseAccessor<SubtracksDatabase> {
Sources get sources => attachedDatabase.sources;
SubsonicSettings get subsonicSettings => attachedDatabase.subsonicSettings;
Artists get artists => attachedDatabase.artists;
Albums get albums => attachedDatabase.albums;
Playlists get playlists => attachedDatabase.playlists;
PlaylistSongs get playlistSongs => attachedDatabase.playlistSongs;
Songs get songs => attachedDatabase.songs;
}

View File

@@ -0,0 +1,87 @@
import 'package:drift/drift.dart';
import '../database.dart';
part 'sources_dao.g.dart';
typedef SourceSetting = ({Source source, SubsonicSetting subsonicSetting});
@DriftAccessor(include: {'../tables.drift'})
class SourcesDao extends DatabaseAccessor<SubtracksDatabase>
with _$SourcesDaoMixin {
SourcesDao(super.db);
Selectable<int?> activeSourceId() {
return (selectOnly(sources)
..addColumns([sources.id])
..where(sources.isActive.equals(true)))
.map((row) => row.read(sources.id));
}
Stream<List<SourceSetting>> listSources() {
final query = select(sources).join([
innerJoin(
subsonicSettings,
sources.id.equalsExp(subsonicSettings.sourceId),
),
]);
return query
.map(
(row) => (
source: row.readTable(sources),
subsonicSetting: row.readTable(subsonicSettings),
),
)
.watch();
}
Selectable<SourceSetting> getSource(int id) {
final query = select(sources).join([
innerJoin(
subsonicSettings,
sources.id.equalsExp(subsonicSettings.sourceId),
),
])..where(sources.id.equals(id));
return query.map(
(row) => (
source: row.readTable(sources),
subsonicSetting: row.readTable(subsonicSettings),
),
);
}
Future<void> deleteSource(int id) async {
await sources.deleteWhere((f) => f.id.equals(id));
}
Future<void> updateSource(Source source, SubsonicSetting subsonic) async {
await db.transaction(() async {
await sources.update().replace(source);
await subsonicSettings.update().replace(subsonic);
});
}
Future<void> createSource(
SourcesCompanion source,
SubsonicSettingsCompanion subsonic,
) async {
await db.transaction(() async {
final sourceId = await sources.insertOnConflictUpdate(source);
await subsonicSettings.insertOnConflictUpdate(
subsonic.copyWith(sourceId: Value(sourceId)),
);
});
}
Future<void> setActiveSource(int id) async {
await transaction(() async {
await db.managers.sources.update((o) => o(isActive: Value(null)));
await db.managers.sources
.filter((f) => f.id.equals(id))
.update((o) => o(isActive: Value(true)));
});
}
}

View File

@@ -0,0 +1,14 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sources_dao.dart';
// ignore_for_file: type=lint
mixin _$SourcesDaoMixin on DatabaseAccessor<SubtracksDatabase> {
Sources get sources => attachedDatabase.sources;
SubsonicSettings get subsonicSettings => attachedDatabase.subsonicSettings;
Artists get artists => attachedDatabase.artists;
Albums get albums => attachedDatabase.albums;
Playlists get playlists => attachedDatabase.playlists;
PlaylistSongs get playlistSongs => attachedDatabase.playlistSongs;
Songs get songs => attachedDatabase.songs;
}

View File

@@ -5,6 +5,9 @@ import 'package:path_provider/path_provider.dart';
import '../sources/models.dart' as models;
import 'converters.dart';
import 'dao/library_dao.dart';
import 'dao/sources_dao.dart';
import 'log_interceptor.dart';
part 'database.g.dart';
@@ -12,21 +15,27 @@ part 'database.g.dart';
// https://www.sqlite.org/limits.html
const kSqliteMaxVariableNumber = 32766;
@DriftDatabase(include: {'tables.drift'})
@DriftDatabase(
include: {'tables.drift'},
daos: [
SourcesDao,
LibraryDao,
],
)
class SubtracksDatabase extends _$SubtracksDatabase {
SubtracksDatabase([QueryExecutor? executor])
: super(executor ?? _openConnection());
static QueryExecutor _openConnection() {
return driftDatabase(
name: 'my_database',
name: 'subtracks_database',
native: DriftNativeOptions(
databasePath: () async {
final directory = await getApplicationSupportDirectory();
return path.join(directory.absolute.path, 'subtracks.sqlite');
},
),
);
).interceptWith(LogInterceptor());
}
@override
@@ -40,395 +49,6 @@ class SubtracksDatabase extends _$SubtracksDatabase {
},
);
}
// MultiSelectable<Album> albumsList(int sourceId, ListQuery opt) {
// return filterAlbums(
// (_) => _filterPredicate('albums', sourceId, opt),
// (_) => _filterOrderBy(opt),
// (_) => _filterLimit(opt),
// );
// }
// MultiSelectable<Album> albumsListDownloaded(int sourceId, ListQuery opt) {
// return filterAlbumsDownloaded(
// (_, __) => _filterPredicate('albums', sourceId, opt),
// (_, __) => _filterOrderBy(opt),
// (_, __) => _filterLimit(opt),
// );
// }
// MultiSelectable<Artist> artistsList(int sourceId, ListQuery opt) {
// return filterArtists(
// (_) => _filterPredicate('artists', sourceId, opt),
// (_) => _filterOrderBy(opt),
// (_) => _filterLimit(opt),
// );
// }
// MultiSelectable<Artist> artistsListDownloaded(int sourceId, ListQuery opt) {
// return filterArtistsDownloaded(
// (_, __, ___) => _filterPredicate('artists', sourceId, opt),
// (_, __, ___) => _filterOrderBy(opt),
// (_, __, ___) => _filterLimit(opt),
// );
// }
// MultiSelectable<Playlist> playlistsList(int sourceId, ListQuery opt) {
// return filterPlaylists(
// (_) => _filterPredicate('playlists', sourceId, opt),
// (_) => _filterOrderBy(opt),
// (_) => _filterLimit(opt),
// );
// }
// MultiSelectable<Playlist> playlistsListDownloaded(
// int sourceId,
// ListQuery opt,
// ) {
// return filterPlaylistsDownloaded(
// (_, __, ___) => _filterPredicate('playlists', sourceId, opt),
// (_, __, ___) => _filterOrderBy(opt),
// (_, __, ___) => _filterLimit(opt),
// );
// }
// MultiSelectable<Song> songsList(int sourceId, ListQuery opt) {
// return filterSongs(
// (_) => _filterPredicate('songs', sourceId, opt),
// (_) => _filterOrderBy(opt),
// (_) => _filterLimit(opt),
// );
// }
// MultiSelectable<Song> songsListDownloaded(int sourceId, ListQuery opt) {
// return filterSongsDownloaded(
// (_) => _filterPredicate('songs', sourceId, opt),
// (_) => _filterOrderBy(opt),
// (_) => _filterLimit(opt),
// );
// }
// Expression<bool> _filterPredicate(String table, int sourceId, ListQuery opt) {
// return opt.filters
// .map((filter) => buildFilter<bool>(filter))
// .fold(
// CustomExpression('$table.source_id = $sourceId'),
// (previousValue, element) => previousValue & element,
// );
// }
// OrderBy _filterOrderBy(ListQuery opt) {
// return opt.sort != null
// ? OrderBy([_buildOrder(opt.sort!)])
// : const OrderBy.nothing();
// }
// Limit _filterLimit(ListQuery opt) {
// return Limit(opt.page.limit, opt.page.offset);
// }
// MultiSelectable<Song> albumSongsList(SourceId sid, ListQuery opt) {
// return listQuery(
// select(songs)..where(
// (tbl) => tbl.sourceId.equals(sid.sourceId) & tbl.albumId.equals(sid.id),
// ),
// opt,
// );
// }
// MultiSelectable<Song> songsByAlbumList(int sourceId, ListQuery opt) {
// return filterSongsByGenre(
// (_, __) => _filterPredicate('songs', sourceId, opt),
// (_, __) => _filterOrderBy(opt),
// (_, __) => _filterLimit(opt),
// );
// }
// MultiSelectable<Song> playlistSongsList(SourceId sid, ListQuery opt) {
// return listQueryJoined(
// select(songs).join([
// innerJoin(
// playlistSongs,
// playlistSongs.sourceId.equalsExp(songs.sourceId) &
// playlistSongs.songId.equalsExp(songs.id),
// useColumns: false,
// ),
// ])..where(
// playlistSongs.sourceId.equals(sid.sourceId) &
// playlistSongs.playlistId.equals(sid.id),
// ),
// opt,
// ).map((row) => row.readTable(songs));
// }
// Future<void> saveArtists(Iterable<ArtistsCompanion> artists) async {
// await batch((batch) {
// batch.insertAllOnConflictUpdate(this.artists, artists);
// });
// }
// Future<void> deleteArtistsNotIn(int sourceId, Set<String> ids) {
// return transaction(() async {
// final allIds =
// (await (selectOnly(artists)
// ..addColumns([artists.id])
// ..where(artists.sourceId.equals(sourceId)))
// .map((row) => row.read(artists.id))
// .get())
// .whereNotNull()
// .toSet();
// final downloadIds = (await artistIdsWithDownloadStatus(
// sourceId,
// ).get()).whereNotNull().toSet();
// final diff = allIds.difference(downloadIds).difference(ids);
// for (var slice in diff.slices(kSqliteMaxVariableNumber)) {
// await (delete(artists)..where(
// (tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isIn(slice),
// ))
// .go();
// }
// });
// }
// Future<void> saveAlbums(Iterable<AlbumsCompanion> albums) async {
// await batch((batch) {
// batch.insertAllOnConflictUpdate(this.albums, albums);
// });
// }
// Future<void> deleteAlbumsNotIn(int sourceId, Set<String> ids) {
// return transaction(() async {
// final allIds =
// (await (selectOnly(albums)
// ..addColumns([albums.id])
// ..where(albums.sourceId.equals(sourceId)))
// .map((row) => row.read(albums.id))
// .get())
// .whereNotNull()
// .toSet();
// final downloadIds = (await albumIdsWithDownloadStatus(
// sourceId,
// ).get()).whereNotNull().toSet();
// final diff = allIds.difference(downloadIds).difference(ids);
// for (var slice in diff.slices(kSqliteMaxVariableNumber)) {
// await (delete(albums)..where(
// (tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isIn(slice),
// ))
// .go();
// }
// });
// }
// Future<void> savePlaylists(
// Iterable<PlaylistWithSongsCompanion> playlistsWithSongs,
// ) async {
// final playlists = playlistsWithSongs.map((e) => e.playist);
// final playlistSongs = playlistsWithSongs.expand((e) => e.songs);
// final sourceId = playlists.first.sourceId.value;
// await (delete(this.playlistSongs)..where(
// (tbl) =>
// tbl.sourceId.equals(sourceId) &
// tbl.playlistId.isIn(playlists.map((e) => e.id.value)),
// ))
// .go();
// await batch((batch) {
// batch.insertAllOnConflictUpdate(this.playlists, playlists);
// batch.insertAllOnConflictUpdate(this.playlistSongs, playlistSongs);
// });
// }
// Future<void> deletePlaylistsNotIn(int sourceId, Set<String> ids) {
// return transaction(() async {
// final allIds =
// (await (selectOnly(playlists)
// ..addColumns([playlists.id])
// ..where(playlists.sourceId.equals(sourceId)))
// .map((row) => row.read(playlists.id))
// .get())
// .whereNotNull()
// .toSet();
// final downloadIds = (await playlistIdsWithDownloadStatus(
// sourceId,
// ).get()).whereNotNull().toSet();
// final diff = allIds.difference(downloadIds).difference(ids);
// for (var slice in diff.slices(kSqliteMaxVariableNumber)) {
// await (delete(playlists)..where(
// (tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isIn(slice),
// ))
// .go();
// await (delete(playlistSongs)..where(
// (tbl) =>
// tbl.sourceId.equals(sourceId) & tbl.playlistId.isIn(slice),
// ))
// .go();
// }
// });
// }
// Future<void> savePlaylistSongs(
// int sourceId,
// List<String> ids,
// Iterable<PlaylistSongsCompanion> playlistSongs,
// ) async {
// await (delete(this.playlistSongs)..where(
// (tbl) => tbl.sourceId.equals(sourceId) & tbl.playlistId.isIn(ids),
// ))
// .go();
// await batch((batch) {
// batch.insertAllOnConflictUpdate(this.playlistSongs, playlistSongs);
// });
// }
// Future<void> saveSongs(Iterable<SongsCompanion> songs) async {
// await batch((batch) {
// batch.insertAllOnConflictUpdate(this.songs, songs);
// });
// }
// Future<void> deleteSongsNotIn(int sourceId, Set<String> ids) {
// return transaction(() async {
// final allIds =
// (await (selectOnly(songs)
// ..addColumns([songs.id])
// ..where(
// songs.sourceId.equals(sourceId) &
// songs.downloadFilePath.isNull() &
// songs.downloadTaskId.isNull(),
// ))
// .map((row) => row.read(songs.id))
// .get())
// .whereNotNull()
// .toSet();
// final diff = allIds.difference(ids);
// for (var slice in diff.slices(kSqliteMaxVariableNumber)) {
// await (delete(songs)..where(
// (tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isIn(slice),
// ))
// .go();
// await (delete(playlistSongs)..where(
// (tbl) => tbl.sourceId.equals(sourceId) & tbl.songId.isIn(slice),
// ))
// .go();
// }
// });
// }
// Selectable<LastBottomNavStateData> getLastBottomNavState() {
// return select(lastBottomNavState)..where((tbl) => tbl.id.equals(1));
// }
// Future<void> saveLastBottomNavState(LastBottomNavStateData update) {
// return into(lastBottomNavState).insertOnConflictUpdate(update);
// }
// Selectable<LastLibraryStateData> getLastLibraryState() {
// return select(lastLibraryState)..where((tbl) => tbl.id.equals(1));
// }
// Future<void> saveLastLibraryState(LastLibraryStateData update) {
// return into(lastLibraryState).insertOnConflictUpdate(update);
// }
// Selectable<LastAudioStateData> getLastAudioState() {
// return select(lastAudioState)..where((tbl) => tbl.id.equals(1));
// }
// Future<void> saveLastAudioState(LastAudioStateCompanion update) {
// return into(lastAudioState).insertOnConflictUpdate(update);
// }
// Future<void> insertQueue(Iterable<QueueCompanion> songs) async {
// await batch((batch) {
// batch.insertAll(queue, songs);
// });
// }
// Future<void> clearQueue() async {
// await delete(queue).go();
// }
// Future<void> setCurrentTrack(int index) async {
// await transaction(() async {
// await (update(queue)..where((tbl) => tbl.index.equals(index).not()))
// .write(const QueueCompanion(currentTrack: Value(null)));
// await (update(queue)..where((tbl) => tbl.index.equals(index))).write(
// const QueueCompanion(currentTrack: Value(true)),
// );
// });
// }
// Future<void> createSource(
// SourcesCompanion source,
// SubsonicSourcesCompanion subsonic,
// ) async {
// await transaction(() async {
// final count = await sourcesCount().getSingle();
// if (count == 0) {
// source = source.copyWith(isActive: const Value(true));
// }
// final id = await into(sources).insert(source);
// subsonic = subsonic.copyWith(sourceId: Value(id));
// await into(subsonicSources).insert(subsonic);
// });
// }
// Future<void> updateSource(SubsonicSettings source) async {
// await transaction(() async {
// await into(sources).insertOnConflictUpdate(source.toSourceInsertable());
// await into(
// subsonicSources,
// ).insertOnConflictUpdate(source.toSubsonicInsertable());
// });
// }
// Future<void> deleteSource(int sourceId) async {
// await transaction(() async {
// await (delete(
// subsonicSources,
// )..where((tbl) => tbl.sourceId.equals(sourceId))).go();
// await (delete(sources)..where((tbl) => tbl.id.equals(sourceId))).go();
// await (delete(songs)..where((tbl) => tbl.sourceId.equals(sourceId))).go();
// await (delete(
// albums,
// )..where((tbl) => tbl.sourceId.equals(sourceId))).go();
// await (delete(
// artists,
// )..where((tbl) => tbl.sourceId.equals(sourceId))).go();
// await (delete(
// playlistSongs,
// )..where((tbl) => tbl.sourceId.equals(sourceId))).go();
// await (delete(
// playlists,
// )..where((tbl) => tbl.sourceId.equals(sourceId))).go();
// });
// }
// Future<void> setActiveSource(int id) async {
// await batch((batch) {
// batch.update(
// sources,
// const SourcesCompanion(isActive: Value(null)),
// where: (t) => t.id.isNotValue(id),
// );
// batch.update(
// sources,
// const SourcesCompanion(isActive: Value(true)),
// where: (t) => t.id.equals(id),
// );
// });
// }
// Future<void> updateSettings(AppSettingsCompanion settings) async {
// await into(appSettings).insertOnConflictUpdate(settings);
// }
}
extension ArtistToDb on models.Artist {
@@ -495,252 +115,3 @@ extension PlaylistSongToDb on models.PlaylistSong {
position: position,
);
}
// LazyDatabase _openConnection() {
// return LazyDatabase(() async {
// final dbFolder = await getApplicationDocumentsDirectory();
// final file = File(p.join(dbFolder.path, 'subtracks.sqlite'));
// // return NativeDatabase.createInBackground(file, logStatements: true);
// return ErrorLoggingDatabase(
// NativeDatabase.createInBackground(file),
// (e, s) => log.severe('SQL error', e, s),
// );
// });
// }
// @Riverpod(keepAlive: true)
// SubtracksDatabase database(DatabaseRef ref) {
// return SubtracksDatabase();
// }
// OrderingTerm _buildOrder(SortBy sort) {
// OrderingMode? mode = sort.dir == SortDirection.asc
// ? OrderingMode.asc
// : OrderingMode.desc;
// return OrderingTerm(
// expression: CustomExpression(sort.column),
// mode: mode,
// );
// }
// SimpleSelectStatement<T, R> listQuery<T extends HasResultSet, R>(
// SimpleSelectStatement<T, R> query,
// ListQuery opt,
// ) {
// if (opt.page.limit > 0) {
// query.limit(opt.page.limit, offset: opt.page.offset);
// }
// if (opt.sort != null) {
// OrderingMode? mode = opt.sort != null && opt.sort!.dir == SortDirection.asc
// ? OrderingMode.asc
// : OrderingMode.desc;
// query.orderBy([
// (t) => OrderingTerm(
// expression: CustomExpression(opt.sort!.column),
// mode: mode,
// ),
// ]);
// }
// for (var filter in opt.filters) {
// query.where((tbl) => buildFilter(filter));
// }
// return query;
// }
// JoinedSelectStatement<T, R> listQueryJoined<T extends HasResultSet, R>(
// JoinedSelectStatement<T, R> query,
// ListQuery opt,
// ) {
// if (opt.page.limit > 0) {
// query.limit(opt.page.limit, offset: opt.page.offset);
// }
// if (opt.sort != null) {
// OrderingMode? mode = opt.sort != null && opt.sort!.dir == SortDirection.asc
// ? OrderingMode.asc
// : OrderingMode.desc;
// query.orderBy([
// OrderingTerm(
// expression: CustomExpression(opt.sort!.column),
// mode: mode,
// ),
// ]);
// }
// for (var filter in opt.filters) {
// query.where(buildFilter(filter));
// }
// return query;
// }
// CustomExpression<T> buildFilter<T extends Object>(
// FilterWith filter,
// ) {
// return filter.when(
// equals: (column, value, invert) => CustomExpression<T>(
// '$column ${invert ? '<>' : '='} \'$value\'',
// ),
// greaterThan: (column, value, orEquals) => CustomExpression<T>(
// '$column ${orEquals ? '>=' : '>'} $value',
// ),
// isNull: (column, invert) => CustomExpression<T>(
// '$column ${invert ? 'IS NOT' : 'IS'} NULL',
// ),
// betweenInt: (column, from, to) => CustomExpression<T>(
// '$column BETWEEN $from AND $to',
// ),
// isIn: (column, invert, values) => CustomExpression<T>(
// '$column ${invert ? 'NOT IN' : 'IN'} (${values.join(',')})',
// ),
// );
// }
// class AlbumSongsCompanion {
// final AlbumsCompanion album;
// final Iterable<SongsCompanion> songs;
// AlbumSongsCompanion(this.album, this.songs);
// }
// class ArtistAlbumsCompanion {
// final ArtistsCompanion artist;
// final Iterable<AlbumsCompanion> albums;
// ArtistAlbumsCompanion(this.artist, this.albums);
// }
// class PlaylistWithSongsCompanion {
// final PlaylistsCompanion playist;
// final Iterable<PlaylistSongsCompanion> songs;
// PlaylistWithSongsCompanion(this.playist, this.songs);
// }
// Future<void> saveArtist(
// SubtracksDatabase db,
// ArtistAlbumsCompanion artistAlbums,
// ) async {
// return db.background((db) async {
// final artist = artistAlbums.artist;
// final albums = artistAlbums.albums;
// await db.batch((batch) {
// batch.insertAllOnConflictUpdate(db.artists, [artist]);
// batch.insertAllOnConflictUpdate(db.albums, albums);
// // remove this artistId from albums not found in source
// // don't delete them since they coud have been moved to another artist
// // that we haven't synced yet
// final albumIds = {for (var a in albums) a.id.value};
// batch.update(
// db.albums,
// const AlbumsCompanion(artistId: Value(null)),
// where: (tbl) =>
// tbl.sourceId.equals(artist.sourceId.value) &
// tbl.artistId.equals(artist.id.value) &
// tbl.id.isNotIn(albumIds),
// );
// });
// });
// }
// Future<void> saveAlbum(
// SubtracksDatabase db,
// AlbumSongsCompanion albumSongs,
// ) async {
// return db.background((db) async {
// final album = albumSongs.album.copyWith(synced: Value(DateTime.now()));
// final songs = albumSongs.songs;
// final songIds = {for (var a in songs) a.id.value};
// final hardDeletedSongIds = (await (db.selectOnly(db.songs)
// ..addColumns([db.songs.id])
// ..where(
// db.songs.sourceId.equals(album.sourceId.value) &
// db.songs.albumId.equals(album.id.value) &
// db.songs.id.isNotIn(songIds) &
// db.songs.downloadFilePath.isNull() &
// db.songs.downloadTaskId.isNull(),
// ))
// .map((row) => row.read(db.songs.id))
// .get())
// .whereNotNull();
// await db.batch((batch) {
// batch.insertAllOnConflictUpdate(db.albums, [album]);
// batch.insertAllOnConflictUpdate(db.songs, songs);
// // soft delete songs that have been downloaded so that the user
// // can decide to keep or remove them later
// // TODO: add a setting to skip soft delete and just remove download too
// batch.update(
// db.songs,
// const SongsCompanion(isDeleted: Value(true)),
// where: (tbl) =>
// tbl.sourceId.equals(album.sourceId.value) &
// tbl.albumId.equals(album.id.value) &
// tbl.id.isNotIn(songIds) &
// (tbl.downloadFilePath.isNotNull() | tbl.downloadTaskId.isNotNull()),
// );
// // safe to hard delete songs that have not been downloaded
// batch.deleteWhere(
// db.songs,
// (tbl) =>
// tbl.sourceId.equals(album.sourceId.value) &
// tbl.id.isIn(hardDeletedSongIds),
// );
// // also need to remove these songs from any playlists that contain them
// batch.deleteWhere(
// db.playlistSongs,
// (tbl) =>
// tbl.sourceId.equals(album.sourceId.value) &
// tbl.songId.isIn(hardDeletedSongIds),
// );
// });
// });
// }
// Future<void> savePlaylist(
// SubtracksDatabase db,
// PlaylistWithSongsCompanion playlistWithSongs,
// ) async {
// return db.background((db) async {
// final playlist =
// playlistWithSongs.playist.copyWith(synced: Value(DateTime.now()));
// final songs = playlistWithSongs.songs;
// await db.batch((batch) {
// batch.insertAllOnConflictUpdate(db.playlists, [playlist]);
// batch.insertAllOnConflictUpdate(db.songs, songs);
// batch.insertAllOnConflictUpdate(
// db.playlistSongs,
// songs.mapIndexed(
// (index, song) => PlaylistSongsCompanion.insert(
// sourceId: playlist.sourceId.value,
// playlistId: playlist.id.value,
// songId: song.id.value,
// position: index,
// ),
// ),
// );
// // the new playlist could be shorter than the old one, so we delete
// // playlist songs above our new playlist's length
// batch.deleteWhere(
// db.playlistSongs,
// (tbl) =>
// tbl.sourceId.equals(playlist.sourceId.value) &
// tbl.playlistId.equals(playlist.id.value) &
// tbl.position.isBiggerOrEqualValue(songs.length),
// );
// });
// });
// }

View File

@@ -1471,6 +1471,15 @@ class Playlists extends Table with TableInfo<Playlists, models.Playlist> {
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
static const VerificationMeta _publicMeta = const VerificationMeta('public');
late final GeneratedColumn<bool> public = GeneratedColumn<bool>(
'public',
aliasedName,
true,
type: DriftSqlType.bool,
requiredDuringInsert: false,
$customConstraints: '',
);
@override
List<GeneratedColumn> get $columns => [
sourceId,
@@ -1480,6 +1489,7 @@ class Playlists extends Table with TableInfo<Playlists, models.Playlist> {
coverArt,
created,
changed,
public,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -1542,6 +1552,12 @@ class Playlists extends Table with TableInfo<Playlists, models.Playlist> {
} else if (isInserting) {
context.missing(_changedMeta);
}
if (data.containsKey('public')) {
context.handle(
_publicMeta,
public.isAcceptableOrUnknown(data['public']!, _publicMeta),
);
}
return context;
}
@@ -1575,6 +1591,10 @@ class Playlists extends Table with TableInfo<Playlists, models.Playlist> {
DriftSqlType.string,
data['${effectivePrefix}cover_art'],
),
public: attachedDatabase.typeMapping.read(
DriftSqlType.bool,
data['${effectivePrefix}public'],
),
);
}
@@ -1600,6 +1620,7 @@ class PlaylistsCompanion extends UpdateCompanion<models.Playlist> {
final Value<String?> coverArt;
final Value<DateTime> created;
final Value<DateTime> changed;
final Value<bool?> public;
final Value<int> rowid;
const PlaylistsCompanion({
this.sourceId = const Value.absent(),
@@ -1609,6 +1630,7 @@ class PlaylistsCompanion extends UpdateCompanion<models.Playlist> {
this.coverArt = const Value.absent(),
this.created = const Value.absent(),
this.changed = const Value.absent(),
this.public = const Value.absent(),
this.rowid = const Value.absent(),
});
PlaylistsCompanion.insert({
@@ -1619,6 +1641,7 @@ class PlaylistsCompanion extends UpdateCompanion<models.Playlist> {
this.coverArt = const Value.absent(),
required DateTime created,
required DateTime changed,
this.public = const Value.absent(),
this.rowid = const Value.absent(),
}) : sourceId = Value(sourceId),
id = Value(id),
@@ -1633,6 +1656,7 @@ class PlaylistsCompanion extends UpdateCompanion<models.Playlist> {
Expression<String>? coverArt,
Expression<DateTime>? created,
Expression<DateTime>? changed,
Expression<bool>? public,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
@@ -1643,6 +1667,7 @@ class PlaylistsCompanion extends UpdateCompanion<models.Playlist> {
if (coverArt != null) 'cover_art': coverArt,
if (created != null) 'created': created,
if (changed != null) 'changed': changed,
if (public != null) 'public': public,
if (rowid != null) 'rowid': rowid,
});
}
@@ -1655,6 +1680,7 @@ class PlaylistsCompanion extends UpdateCompanion<models.Playlist> {
Value<String?>? coverArt,
Value<DateTime>? created,
Value<DateTime>? changed,
Value<bool?>? public,
Value<int>? rowid,
}) {
return PlaylistsCompanion(
@@ -1665,6 +1691,7 @@ class PlaylistsCompanion extends UpdateCompanion<models.Playlist> {
coverArt: coverArt ?? this.coverArt,
created: created ?? this.created,
changed: changed ?? this.changed,
public: public ?? this.public,
rowid: rowid ?? this.rowid,
);
}
@@ -1693,6 +1720,9 @@ class PlaylistsCompanion extends UpdateCompanion<models.Playlist> {
if (changed.present) {
map['changed'] = Variable<DateTime>(changed.value);
}
if (public.present) {
map['public'] = Variable<bool>(public.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
@@ -1709,6 +1739,7 @@ class PlaylistsCompanion extends UpdateCompanion<models.Playlist> {
..write('coverArt: $coverArt, ')
..write('created: $created, ')
..write('changed: $changed, ')
..write('public: $public, ')
..write('rowid: $rowid')
..write(')'))
.toString();
@@ -2454,6 +2485,8 @@ abstract class _$SubtracksDatabase extends GeneratedDatabase {
'songs_source_id_artist_id_idx',
'CREATE INDEX songs_source_id_artist_id_idx ON songs (source_id, artist_id)',
);
late final SourcesDao sourcesDao = SourcesDao(this as SubtracksDatabase);
late final LibraryDao libraryDao = LibraryDao(this as SubtracksDatabase);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@@ -3430,6 +3463,7 @@ typedef $PlaylistsCreateCompanionBuilder =
Value<String?> coverArt,
required DateTime created,
required DateTime changed,
Value<bool?> public,
Value<int> rowid,
});
typedef $PlaylistsUpdateCompanionBuilder =
@@ -3441,6 +3475,7 @@ typedef $PlaylistsUpdateCompanionBuilder =
Value<String?> coverArt,
Value<DateTime> created,
Value<DateTime> changed,
Value<bool?> public,
Value<int> rowid,
});
@@ -3487,6 +3522,11 @@ class $PlaylistsFilterComposer
column: $table.changed,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<bool> get public => $composableBuilder(
column: $table.public,
builder: (column) => ColumnFilters(column),
);
}
class $PlaylistsOrderingComposer
@@ -3532,6 +3572,11 @@ class $PlaylistsOrderingComposer
column: $table.changed,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<bool> get public => $composableBuilder(
column: $table.public,
builder: (column) => ColumnOrderings(column),
);
}
class $PlaylistsAnnotationComposer
@@ -3563,6 +3608,9 @@ class $PlaylistsAnnotationComposer
GeneratedColumn<DateTime> get changed =>
$composableBuilder(column: $table.changed, builder: (column) => column);
GeneratedColumn<bool> get public =>
$composableBuilder(column: $table.public, builder: (column) => column);
}
class $PlaylistsTableManager
@@ -3603,6 +3651,7 @@ class $PlaylistsTableManager
Value<String?> coverArt = const Value.absent(),
Value<DateTime> created = const Value.absent(),
Value<DateTime> changed = const Value.absent(),
Value<bool?> public = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => PlaylistsCompanion(
sourceId: sourceId,
@@ -3612,6 +3661,7 @@ class $PlaylistsTableManager
coverArt: coverArt,
created: created,
changed: changed,
public: public,
rowid: rowid,
),
createCompanionCallback:
@@ -3623,6 +3673,7 @@ class $PlaylistsTableManager
Value<String?> coverArt = const Value.absent(),
required DateTime created,
required DateTime changed,
Value<bool?> public = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => PlaylistsCompanion.insert(
sourceId: sourceId,
@@ -3632,6 +3683,7 @@ class $PlaylistsTableManager
coverArt: coverArt,
created: created,
changed: changed,
public: public,
rowid: rowid,
),
withReferenceMapper: (p0) => p0

View File

@@ -0,0 +1,120 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:logger/logger.dart';
import '../util/logger.dart';
/// https://drift.simonbinder.eu/examples/tracing/
class LogInterceptor extends QueryInterceptor {
Future<T> _run<T>(
String description,
FutureOr<T> Function() operation,
) async {
final trace = logger.level >= Level.trace;
final stopwatch = trace ? (Stopwatch()..start()) : null;
logger.t('Running $description');
try {
final result = await operation();
if (trace) {
logger.t(' => succeeded after ${stopwatch!.elapsedMilliseconds}ms');
}
return result;
} on Object catch (e, st) {
if (trace) {
logger.t(' => failed after ${stopwatch!.elapsedMilliseconds}ms');
}
logger.e('Query failed', error: e, stackTrace: st);
rethrow;
}
}
@override
TransactionExecutor beginTransaction(QueryExecutor parent) {
logger.t('begin');
return super.beginTransaction(parent);
}
@override
Future<void> commitTransaction(TransactionExecutor inner) {
return _run('commit', () => inner.send());
}
@override
Future<void> rollbackTransaction(TransactionExecutor inner) {
return _run('rollback', () => inner.rollback());
}
@override
Future<void> runBatched(
QueryExecutor executor,
BatchedStatements statements,
) {
return _run(
'batch with $statements',
() => executor.runBatched(statements),
);
}
@override
Future<int> runInsert(
QueryExecutor executor,
String statement,
List<Object?> args,
) {
return _run(
'$statement with $args',
() => executor.runInsert(statement, args),
);
}
@override
Future<int> runUpdate(
QueryExecutor executor,
String statement,
List<Object?> args,
) {
return _run(
'$statement with $args',
() => executor.runUpdate(statement, args),
);
}
@override
Future<int> runDelete(
QueryExecutor executor,
String statement,
List<Object?> args,
) {
return _run(
'$statement with $args',
() => executor.runDelete(statement, args),
);
}
@override
Future<void> runCustom(
QueryExecutor executor,
String statement,
List<Object?> args,
) {
return _run(
'$statement with $args',
() => executor.runCustom(statement, args),
);
}
@override
Future<List<Map<String, Object?>>> runSelect(
QueryExecutor executor,
String statement,
List<Object?> args,
) {
return _run(
'$statement with $args',
() => executor.runSelect(statement, args),
);
}
}

191
lib/database/query.dart Normal file
View File

@@ -0,0 +1,191 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'query.freezed.dart';
part 'query.g.dart';
enum SortDirection {
asc,
desc,
}
enum AlbumsColumn {
name,
created,
year,
starred,
}
enum ArtistsColumn {
name,
starred,
albumCount,
}
enum SongsColumn {
title,
starred,
disc,
track,
album,
artist,
albumArtist,
playlistPosition,
}
enum PlaylistsColumn {
name,
created,
}
@freezed
abstract class SortingTerm with _$SortingTerm {
const factory SortingTerm.albums({
required SortDirection dir,
required AlbumsColumn by,
}) = AlbumsSortingTerm;
static AlbumsSortingTerm albumsAsc(AlbumsColumn by) {
return AlbumsSortingTerm(dir: SortDirection.asc, by: by);
}
static AlbumsSortingTerm albumsDesc(AlbumsColumn by) {
return AlbumsSortingTerm(dir: SortDirection.desc, by: by);
}
const factory SortingTerm.artists({
required SortDirection dir,
required ArtistsColumn by,
}) = ArtistsSortingTerm;
static ArtistsSortingTerm artistsAsc(ArtistsColumn by) {
return ArtistsSortingTerm(dir: SortDirection.asc, by: by);
}
static ArtistsSortingTerm artistsDesc(ArtistsColumn by) {
return ArtistsSortingTerm(dir: SortDirection.desc, by: by);
}
const factory SortingTerm.songs({
required SortDirection dir,
required SongsColumn by,
}) = SongsSortingTerm;
static SongsSortingTerm songsAsc(SongsColumn by) {
return SongsSortingTerm(dir: SortDirection.asc, by: by);
}
static SongsSortingTerm songsDesc(SongsColumn by) {
return SongsSortingTerm(dir: SortDirection.desc, by: by);
}
const factory SortingTerm.playlists({
required SortDirection dir,
required PlaylistsColumn by,
}) = PlaylistsSortingTerm;
static PlaylistsSortingTerm playlistsAsc(PlaylistsColumn by) {
return PlaylistsSortingTerm(dir: SortDirection.asc, by: by);
}
static PlaylistsSortingTerm playlistsDesc(PlaylistsColumn by) {
return PlaylistsSortingTerm(dir: SortDirection.desc, by: by);
}
factory SortingTerm.fromJson(Map<String, Object?> json) =>
_$SortingTermFromJson(json);
}
@freezed
abstract class AlbumsQuery with _$AlbumsQuery {
const factory AlbumsQuery({
required int sourceId,
@Default(IListConst([])) IList<AlbumsFilter> filter,
required IList<AlbumsSortingTerm> sort,
int? limit,
int? offset,
}) = _AlbumsQuery;
factory AlbumsQuery.fromJson(Map<String, Object?> json) =>
_$AlbumsQueryFromJson(json);
}
@freezed
abstract class AlbumsFilter with _$AlbumsFilter {
const factory AlbumsFilter.artistId(String artistId) = AlbumsFilterArtistId;
const factory AlbumsFilter.yearEquals(int year) = AlbumsFilterYearEquals;
factory AlbumsFilter.fromJson(Map<String, Object?> json) =>
_$AlbumsFilterFromJson(json);
}
@freezed
abstract class ArtistsQuery with _$ArtistsQuery {
const factory ArtistsQuery({
required int sourceId,
@Default(IListConst([])) IList<ArtistsFilter> filter,
required IList<ArtistsSortingTerm> sort,
int? limit,
int? offset,
}) = _ArtistsQuery;
factory ArtistsQuery.fromJson(Map<String, Object?> json) =>
_$ArtistsQueryFromJson(json);
}
@freezed
abstract class ArtistsFilter with _$ArtistsFilter {
const factory ArtistsFilter.nameSearch(String name) = ArtistsFilterNameSearch;
const factory ArtistsFilter.starred(bool starred) = ArtistsFilterStarred;
factory ArtistsFilter.fromJson(Map<String, Object?> json) =>
_$ArtistsFilterFromJson(json);
}
@freezed
abstract class SongsQuery with _$SongsQuery {
const factory SongsQuery({
required int sourceId,
@Default(IListConst([])) IList<SongsFilter> filter,
required IList<SongsSortingTerm> sort,
int? limit,
int? offset,
}) = _SongsQuery;
factory SongsQuery.fromJson(Map<String, Object?> json) =>
_$SongsQueryFromJson(json);
}
@freezed
abstract class SongsFilter with _$SongsFilter {
const factory SongsFilter.albumId(String albumId) = SongsFilterAlbumId;
const factory SongsFilter.playlistId(String playlistId) =
SongsFilterPlaylistId;
factory SongsFilter.fromJson(Map<String, Object?> json) =>
_$SongsFilterFromJson(json);
}
@freezed
abstract class PlaylistsQuery with _$PlaylistsQuery {
const factory PlaylistsQuery({
required int sourceId,
@Default(IListConst([])) IList<PlaylistsFilter> filter,
required IList<PlaylistsSortingTerm> sort,
int? limit,
int? offset,
}) = _PlaylistsQuery;
factory PlaylistsQuery.fromJson(Map<String, Object?> json) =>
_$PlaylistsQueryFromJson(json);
}
@freezed
abstract class PlaylistsFilter with _$PlaylistsFilter {
const factory PlaylistsFilter.nameSearch(String name) =
PlaylistsFilterNameSearch;
const factory PlaylistsFilter.public(bool public) = PlaylistsFilterPublic;
factory PlaylistsFilter.fromJson(Map<String, Object?> json) =>
_$PlaylistsFilterFromJson(json);
}

File diff suppressed because it is too large Load Diff

303
lib/database/query.g.dart Normal file
View File

@@ -0,0 +1,303 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'query.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AlbumsSortingTerm _$AlbumsSortingTermFromJson(Map<String, dynamic> json) =>
AlbumsSortingTerm(
dir: $enumDecode(_$SortDirectionEnumMap, json['dir']),
by: $enumDecode(_$AlbumsColumnEnumMap, json['by']),
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$AlbumsSortingTermToJson(AlbumsSortingTerm instance) =>
<String, dynamic>{
'dir': _$SortDirectionEnumMap[instance.dir]!,
'by': _$AlbumsColumnEnumMap[instance.by]!,
'runtimeType': instance.$type,
};
const _$SortDirectionEnumMap = {
SortDirection.asc: 'asc',
SortDirection.desc: 'desc',
};
const _$AlbumsColumnEnumMap = {
AlbumsColumn.name: 'name',
AlbumsColumn.created: 'created',
AlbumsColumn.year: 'year',
AlbumsColumn.starred: 'starred',
};
ArtistsSortingTerm _$ArtistsSortingTermFromJson(Map<String, dynamic> json) =>
ArtistsSortingTerm(
dir: $enumDecode(_$SortDirectionEnumMap, json['dir']),
by: $enumDecode(_$ArtistsColumnEnumMap, json['by']),
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$ArtistsSortingTermToJson(ArtistsSortingTerm instance) =>
<String, dynamic>{
'dir': _$SortDirectionEnumMap[instance.dir]!,
'by': _$ArtistsColumnEnumMap[instance.by]!,
'runtimeType': instance.$type,
};
const _$ArtistsColumnEnumMap = {
ArtistsColumn.name: 'name',
ArtistsColumn.starred: 'starred',
ArtistsColumn.albumCount: 'albumCount',
};
SongsSortingTerm _$SongsSortingTermFromJson(Map<String, dynamic> json) =>
SongsSortingTerm(
dir: $enumDecode(_$SortDirectionEnumMap, json['dir']),
by: $enumDecode(_$SongsColumnEnumMap, json['by']),
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$SongsSortingTermToJson(SongsSortingTerm instance) =>
<String, dynamic>{
'dir': _$SortDirectionEnumMap[instance.dir]!,
'by': _$SongsColumnEnumMap[instance.by]!,
'runtimeType': instance.$type,
};
const _$SongsColumnEnumMap = {
SongsColumn.title: 'title',
SongsColumn.starred: 'starred',
SongsColumn.disc: 'disc',
SongsColumn.track: 'track',
SongsColumn.album: 'album',
SongsColumn.artist: 'artist',
SongsColumn.albumArtist: 'albumArtist',
SongsColumn.playlistPosition: 'playlistPosition',
};
PlaylistsSortingTerm _$PlaylistsSortingTermFromJson(
Map<String, dynamic> json,
) => PlaylistsSortingTerm(
dir: $enumDecode(_$SortDirectionEnumMap, json['dir']),
by: $enumDecode(_$PlaylistsColumnEnumMap, json['by']),
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$PlaylistsSortingTermToJson(
PlaylistsSortingTerm instance,
) => <String, dynamic>{
'dir': _$SortDirectionEnumMap[instance.dir]!,
'by': _$PlaylistsColumnEnumMap[instance.by]!,
'runtimeType': instance.$type,
};
const _$PlaylistsColumnEnumMap = {
PlaylistsColumn.name: 'name',
PlaylistsColumn.created: 'created',
};
_AlbumsQuery _$AlbumsQueryFromJson(Map<String, dynamic> json) => _AlbumsQuery(
sourceId: (json['sourceId'] as num).toInt(),
filter: json['filter'] == null
? const IListConst([])
: IList<AlbumsFilter>.fromJson(
json['filter'],
(value) => AlbumsFilter.fromJson(value as Map<String, dynamic>),
),
sort: IList<AlbumsSortingTerm>.fromJson(
json['sort'],
(value) => AlbumsSortingTerm.fromJson(value as Map<String, dynamic>),
),
limit: (json['limit'] as num?)?.toInt(),
offset: (json['offset'] as num?)?.toInt(),
);
Map<String, dynamic> _$AlbumsQueryToJson(_AlbumsQuery instance) =>
<String, dynamic>{
'sourceId': instance.sourceId,
'filter': instance.filter.toJson((value) => value),
'sort': instance.sort.toJson((value) => value),
'limit': instance.limit,
'offset': instance.offset,
};
AlbumsFilterArtistId _$AlbumsFilterArtistIdFromJson(
Map<String, dynamic> json,
) => AlbumsFilterArtistId(
json['artistId'] as String,
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$AlbumsFilterArtistIdToJson(
AlbumsFilterArtistId instance,
) => <String, dynamic>{
'artistId': instance.artistId,
'runtimeType': instance.$type,
};
AlbumsFilterYearEquals _$AlbumsFilterYearEqualsFromJson(
Map<String, dynamic> json,
) => AlbumsFilterYearEquals(
(json['year'] as num).toInt(),
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$AlbumsFilterYearEqualsToJson(
AlbumsFilterYearEquals instance,
) => <String, dynamic>{'year': instance.year, 'runtimeType': instance.$type};
_ArtistsQuery _$ArtistsQueryFromJson(Map<String, dynamic> json) =>
_ArtistsQuery(
sourceId: (json['sourceId'] as num).toInt(),
filter: json['filter'] == null
? const IListConst([])
: IList<ArtistsFilter>.fromJson(
json['filter'],
(value) => ArtistsFilter.fromJson(value as Map<String, dynamic>),
),
sort: IList<ArtistsSortingTerm>.fromJson(
json['sort'],
(value) => ArtistsSortingTerm.fromJson(value as Map<String, dynamic>),
),
limit: (json['limit'] as num?)?.toInt(),
offset: (json['offset'] as num?)?.toInt(),
);
Map<String, dynamic> _$ArtistsQueryToJson(_ArtistsQuery instance) =>
<String, dynamic>{
'sourceId': instance.sourceId,
'filter': instance.filter.toJson((value) => value),
'sort': instance.sort.toJson((value) => value),
'limit': instance.limit,
'offset': instance.offset,
};
ArtistsFilterNameSearch _$ArtistsFilterNameSearchFromJson(
Map<String, dynamic> json,
) => ArtistsFilterNameSearch(
json['name'] as String,
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$ArtistsFilterNameSearchToJson(
ArtistsFilterNameSearch instance,
) => <String, dynamic>{'name': instance.name, 'runtimeType': instance.$type};
ArtistsFilterStarred _$ArtistsFilterStarredFromJson(
Map<String, dynamic> json,
) => ArtistsFilterStarred(
json['starred'] as bool,
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$ArtistsFilterStarredToJson(
ArtistsFilterStarred instance,
) => <String, dynamic>{
'starred': instance.starred,
'runtimeType': instance.$type,
};
_SongsQuery _$SongsQueryFromJson(Map<String, dynamic> json) => _SongsQuery(
sourceId: (json['sourceId'] as num).toInt(),
filter: json['filter'] == null
? const IListConst([])
: IList<SongsFilter>.fromJson(
json['filter'],
(value) => SongsFilter.fromJson(value as Map<String, dynamic>),
),
sort: IList<SongsSortingTerm>.fromJson(
json['sort'],
(value) => SongsSortingTerm.fromJson(value as Map<String, dynamic>),
),
limit: (json['limit'] as num?)?.toInt(),
offset: (json['offset'] as num?)?.toInt(),
);
Map<String, dynamic> _$SongsQueryToJson(_SongsQuery instance) =>
<String, dynamic>{
'sourceId': instance.sourceId,
'filter': instance.filter.toJson((value) => value),
'sort': instance.sort.toJson((value) => value),
'limit': instance.limit,
'offset': instance.offset,
};
SongsFilterAlbumId _$SongsFilterAlbumIdFromJson(Map<String, dynamic> json) =>
SongsFilterAlbumId(
json['albumId'] as String,
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$SongsFilterAlbumIdToJson(SongsFilterAlbumId instance) =>
<String, dynamic>{
'albumId': instance.albumId,
'runtimeType': instance.$type,
};
SongsFilterPlaylistId _$SongsFilterPlaylistIdFromJson(
Map<String, dynamic> json,
) => SongsFilterPlaylistId(
json['playlistId'] as String,
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$SongsFilterPlaylistIdToJson(
SongsFilterPlaylistId instance,
) => <String, dynamic>{
'playlistId': instance.playlistId,
'runtimeType': instance.$type,
};
_PlaylistsQuery _$PlaylistsQueryFromJson(Map<String, dynamic> json) =>
_PlaylistsQuery(
sourceId: (json['sourceId'] as num).toInt(),
filter: json['filter'] == null
? const IListConst([])
: IList<PlaylistsFilter>.fromJson(
json['filter'],
(value) =>
PlaylistsFilter.fromJson(value as Map<String, dynamic>),
),
sort: IList<PlaylistsSortingTerm>.fromJson(
json['sort'],
(value) => PlaylistsSortingTerm.fromJson(value as Map<String, dynamic>),
),
limit: (json['limit'] as num?)?.toInt(),
offset: (json['offset'] as num?)?.toInt(),
);
Map<String, dynamic> _$PlaylistsQueryToJson(_PlaylistsQuery instance) =>
<String, dynamic>{
'sourceId': instance.sourceId,
'filter': instance.filter.toJson((value) => value),
'sort': instance.sort.toJson((value) => value),
'limit': instance.limit,
'offset': instance.offset,
};
PlaylistsFilterNameSearch _$PlaylistsFilterNameSearchFromJson(
Map<String, dynamic> json,
) => PlaylistsFilterNameSearch(
json['name'] as String,
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$PlaylistsFilterNameSearchToJson(
PlaylistsFilterNameSearch instance,
) => <String, dynamic>{'name': instance.name, 'runtimeType': instance.$type};
PlaylistsFilterPublic _$PlaylistsFilterPublicFromJson(
Map<String, dynamic> json,
) => PlaylistsFilterPublic(
json['public'] as bool,
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$PlaylistsFilterPublicToJson(
PlaylistsFilterPublic instance,
) => <String, dynamic>{
'public': instance.public,
'runtimeType': instance.$type,
};

View File

@@ -136,6 +136,7 @@ CREATE TABLE playlists(
cover_art TEXT,
created DATETIME NOT NULL,
changed DATETIME NOT NULL,
public BOOLEAN,
PRIMARY KEY (source_id, id),
FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE
) WITH Playlist;

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'app/router.dart';
import 'app/ui/theme.dart';
import 'l10n/generated/app_localizations.dart';
void main() async {
@@ -17,7 +18,7 @@ class MainApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp.router(
themeMode: ThemeMode.dark,
darkTheme: ThemeData.dark(useMaterial3: true),
darkTheme: subtracksTheme(),
debugShowCheckedModeBanner: false,
routerConfig: router,
localizationsDelegates: AppLocalizations.localizationsDelegates,

View File

@@ -1,13 +1,14 @@
import 'package:async/async.dart';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import '../database/database.dart';
import '../sources/music_source.dart';
const kSliceSize = 200;
class SyncService {
class SyncService with ChangeNotifier {
SyncService({
required this.source,
required this.db,
@@ -28,6 +29,7 @@ class SyncService {
syncPlaylistSongs(),
]);
});
notifyListeners();
}
Future<void> syncArtists() async {

View File

@@ -34,6 +34,7 @@ Playlist mapPlaylist(XmlElement e) => Playlist(
coverArt: e.getAttribute('coverArt'),
created: DateTime.parse(e.getAttribute('created')!),
changed: DateTime.parse(e.getAttribute('changed')!),
public: bool.tryParse(e.getAttribute('public') ?? ''),
owner: e.getAttribute('owner'),
);

48
lib/util/logger.dart Normal file
View File

@@ -0,0 +1,48 @@
import 'package:logger/logger.dart';
class LogLevelFilter extends LogFilter {
@override
bool shouldLog(LogEvent event) {
return event.level >= level!;
}
}
class SubtracksLogger extends Logger {
SubtracksLogger({
super.filter,
super.printer,
super.output,
required Level level,
}) : _level = level,
super(level: level);
final Level _level;
Level get level => _level;
}
SubtracksLogger createLogger() {
var isDebug = false;
assert(() {
isDebug = true;
return true;
}());
if (isDebug) {
return SubtracksLogger(
filter: DevelopmentFilter(),
printer: PrettyPrinter(),
output: ConsoleOutput(),
level: Level.debug,
);
}
// TODO: production logger
return SubtracksLogger(
filter: DevelopmentFilter(),
printer: PrettyPrinter(),
output: ConsoleOutput(),
level: Level.debug,
);
}
final logger = createLogger();

View File

@@ -1,6 +1,6 @@
[tools]
android-sdk = "latest"
deno = "2.5.3"
flutter = "3.35"
flutter = "3.38"
java = "17"
yq = "latest"

View File

@@ -5,34 +5,34 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
url: "https://pub.dev"
source: hosted
version: "85.0.0"
version: "91.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c
sha256: a40a0cee526a7e1f387c6847bd8a5ccbf510a75952ef8a28338e989558072cb0
url: "https://pub.dev"
source: hosted
version: "7.6.0"
version: "8.4.0"
analyzer_buffer:
dependency: transitive
description:
name: analyzer_buffer
sha256: f7833bee67c03c37241c67f8741b17cc501b69d9758df7a5a4a13ed6c947be43
sha256: aba2f75e63b3135fd1efaa8b6abefe1aa6e41b6bd9806221620fa48f98156033
url: "https://pub.dev"
source: hosted
version: "0.1.10"
version: "0.1.11"
analyzer_plugin:
dependency: transitive
description:
name: analyzer_plugin
sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce
sha256: "08cfefa90b4f4dd3b447bda831cecf644029f9f8e22820f6ee310213ebe2dd53"
url: "https://pub.dev"
source: hosted
version: "0.13.4"
version: "0.13.10"
args:
dependency: transitive
description:
@@ -61,10 +61,10 @@ packages:
dependency: transitive
description:
name: build
sha256: ce76b1d48875e3233fde17717c23d1f60a91cc631597e49a400c89b475395b1d
sha256: c1668065e9ba04752570ad7e038288559d1e2ca5c6d0131c0f5f55e39e777413
url: "https://pub.dev"
source: hosted
version: "3.1.0"
version: "4.0.3"
build_config:
dependency: transitive
description:
@@ -77,34 +77,18 @@ packages:
dependency: transitive
description:
name: build_daemon
sha256: "409002f1adeea601018715d613115cfaf0e31f512cb80ae4534c79867ae2363d"
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.dev"
source: hosted
version: "4.1.0"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
sha256: d1d57f7807debd7349b4726a19fd32ec8bc177c71ad0febf91a20f84cd2d4b46
url: "https://pub.dev"
source: hosted
version: "3.0.3"
version: "4.1.1"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: b24597fceb695969d47025c958f3837f9f0122e237c6a22cb082a5ac66c3ca30
sha256: "110c56ef29b5eb367b4d17fc79375fa8c18a6cd7acd92c05bb3986c17a079057"
url: "https://pub.dev"
source: hosted
version: "2.7.1"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: "066dda7f73d8eb48ba630a55acb50c4a84a2e6b453b1cb4567f581729e794f7b"
url: "https://pub.dev"
source: hosted
version: "9.3.1"
version: "2.10.4"
built_collection:
dependency: transitive
description:
@@ -117,10 +101,10 @@ packages:
dependency: transitive
description:
name: built_value
sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d
sha256: "426cf75afdb23aa74bd4e471704de3f9393f3c7b04c1e2d9c6f1073ae0b8b139"
url: "https://pub.dev"
source: hosted
version: "8.12.0"
version: "8.12.1"
cached_network_image:
dependency: "direct main"
description:
@@ -245,50 +229,50 @@ packages:
dependency: "direct main"
description:
name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.6"
version: "3.0.7"
custom_lint:
dependency: "direct dev"
description:
name: custom_lint
sha256: "78085fbe842de7c5bef92de811ca81536968dbcbbcdac5c316711add2d15e796"
sha256: "751ee9440920f808266c3ec2553420dea56d3c7837dd2d62af76b11be3fcece5"
url: "https://pub.dev"
source: hosted
version: "0.8.0"
version: "0.8.1"
custom_lint_builder:
dependency: transitive
description:
name: custom_lint_builder
sha256: cc5532d5733d4eccfccaaec6070a1926e9f21e613d93ad0927fad020b95c9e52
sha256: "1128db6f58e71d43842f3b9be7465c83f0c47f4dd8918f878dd6ad3b72a32072"
url: "https://pub.dev"
source: hosted
version: "0.8.0"
version: "0.8.1"
custom_lint_core:
dependency: transitive
description:
name: custom_lint_core
sha256: cc4684d22ca05bf0a4a51127e19a8aea576b42079ed2bc9e956f11aaebe35dd1
sha256: "85b339346154d5646952d44d682965dfe9e12cae5febd706f0db3aa5010d6423"
url: "https://pub.dev"
source: hosted
version: "0.8.0"
version: "0.8.1"
custom_lint_visitor:
dependency: transitive
description:
name: custom_lint_visitor
sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2"
sha256: "91f2a81e9f0abb4b9f3bb529f78b6227ce6050300d1ae5b1e2c69c66c7a566d8"
url: "https://pub.dev"
source: hosted
version: "1.0.0+7.7.0"
version: "1.0.0+8.4.0"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b
url: "https://pub.dev"
source: hosted
version: "3.1.1"
version: "3.1.3"
drift:
dependency: "direct main"
description:
@@ -449,10 +433,10 @@ packages:
dependency: "direct main"
description:
name: go_router
sha256: e1d7ffb0db475e6e845eb58b44768f50b830e23960e3df6908924acd8f7f70ea
sha256: c92d18e1fe994cb06d48aa786c46b142a5633067e8297cff6b5a3ac742620104
url: "https://pub.dev"
source: hosted
version: "16.2.5"
version: "17.0.0"
graphs:
dependency: transitive
description:
@@ -481,10 +465,10 @@ packages:
dependency: "direct main"
description:
name: http
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
@@ -545,10 +529,10 @@ packages:
dependency: "direct dev"
description:
name: json_serializable
sha256: "33a040668b31b320aafa4822b7b1e177e163fc3c1e835c6750319d4ab23aa6fe"
sha256: c5b2ee75210a0f263c6c7b9eeea80553dbae96ea1bf57f02484e806a3ffdffa3
url: "https://pub.dev"
source: hosted
version: "6.11.1"
version: "6.11.2"
leak_tracker:
dependency: transitive
description:
@@ -581,6 +565,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
logger:
dependency: "direct main"
description:
name: logger
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
url: "https://pub.dev"
source: hosted
version: "2.6.2"
logging:
dependency: transitive
description:
@@ -598,7 +590,7 @@ packages:
source: hosted
version: "0.12.17"
material_color_utilities:
dependency: transitive
dependency: "direct main"
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
@@ -617,10 +609,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -637,6 +629,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.2"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "1f81ed9e41909d44162d7ec8663b2c647c202317cc0b56d3d56f6a13146a0b64"
url: "https://pub.dev"
source: hosted
version: "9.1.0"
octo_image:
dependency: "direct main"
description:
@@ -689,18 +689,18 @@ packages:
dependency: transitive
description:
name: path_provider_android
sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
url: "https://pub.dev"
source: hosted
version: "2.2.20"
version: "2.2.22"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738
sha256: "6192e477f34018ef1ea790c56fffc7302e3bc3efede9e798b934c252c8c105ba"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
version: "2.5.0"
path_provider_linux:
dependency: transitive
description:
@@ -851,7 +851,7 @@ packages:
source: sdk
version: "0.0.0"
sliver_tools:
dependency: transitive
dependency: "direct main"
description:
name: sliver_tools
sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6
@@ -862,10 +862,10 @@ packages:
dependency: transitive
description:
name: source_gen
sha256: "7b19d6ba131c6eb98bfcbf8d56c1a7002eba438af2e7ae6f8398b2b0f4f381e3"
sha256: "07b277b67e0096c45196cbddddf2d8c6ffc49342e88bf31d460ce04605ddac75"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
version: "4.1.1"
source_helper:
dependency: transitive
description:
@@ -898,14 +898,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.1"
sprintf:
dependency: transitive
description:
name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
sqflite:
dependency: transitive
description:
@@ -1030,34 +1022,26 @@ packages:
dependency: "direct dev"
description:
name: test
sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb"
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev"
source: hosted
version: "1.26.2"
version: "1.26.3"
test_api:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.7"
test_core:
dependency: transitive
description:
name: test_core
sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a"
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev"
source: hosted
version: "0.6.11"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
version: "0.6.12"
typed_data:
dependency: transitive
description:
@@ -1078,34 +1062,34 @@ packages:
dependency: transitive
description:
name: url_launcher_android
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
url: "https://pub.dev"
source: hosted
version: "6.3.24"
version: "6.3.28"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9"
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
url: "https://pub.dev"
source: hosted
version: "6.3.5"
version: "6.3.6"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.1"
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9"
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.4"
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
@@ -1126,18 +1110,18 @@ packages:
dependency: transitive
description:
name: url_launcher_windows
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.4"
version: "3.1.5"
uuid:
dependency: transitive
description:
name: uuid
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
url: "https://pub.dev"
source: hosted
version: "4.5.1"
version: "4.5.2"
vector_math:
dependency: transitive
description:

View File

@@ -20,18 +20,21 @@ dependencies:
flutter_localizations:
sdk: flutter
freezed_annotation: ^3.1.0
go_router: ^16.2.5
go_router: ^17.0.0
hooks_riverpod: ^3.0.3
http: ^1.5.0
infinite_scroll_pagination: ^5.1.1
intl: any
json_annotation: ^4.9.0
logger: ^2.6.2
material_color_utilities: ^0.11.1
material_symbols_icons: ^4.2874.0
octo_image: ^2.1.0
package_info_plus: ^9.0.0
path: ^1.9.1
path_provider: ^2.1.5
pool: ^1.5.2
sliver_tools: ^0.2.12
url_launcher: ^6.3.2
xml: ^6.6.1