From 5986482bafe1abdfcef4ad915e7bd2dbe3a0ac93 Mon Sep 17 00:00:00 2001 From: "Dr.Blank" Date: Sat, 19 Apr 2025 19:17:31 +0530 Subject: [PATCH] feat: ability to change library (#77) * feat: add AbsIcons font and update pubspec.yaml for font integration * feat: implement library selection in YouPage * fix: optimize authenticatedApi provider to not rebuild unnecessarily * feat: add LibrarySwitchChip widget and integrate it into YouPage and ScaffoldWithNavBar * feat: enhance library selection UI with refresh functionality and error handling * fix: change library switcher activation from long press to double tap * feat: show current library on nav bar * feat: refactor LibraryBrowserPage to use CustomScrollView and enhance app bar with dynamic library icon and title --- assets/fonts/AbsIcons.ttf | Bin 0 -> 5972 bytes lib/api/api_provider.dart | 3 +- lib/api/api_provider.g.dart | 2 +- lib/api/library_provider.dart | 58 +++++ lib/api/library_provider.g.dart | 192 +++++++++++++++ .../view/library_browser_page.dart | 104 +++++--- lib/features/logging/view/logs_page.dart | 5 - .../you/view/widgets/library_switch_chip.dart | 224 ++++++++++++++++++ lib/features/you/view/you_page.dart | 60 ++--- lib/router/scaffold_with_nav_bar.dart | 56 ++++- lib/shared/icons/abs_icons.dart | 103 ++++++++ lib/shared/widgets/vaani_logo.dart | 27 +++ pubspec.yaml | 5 + shelfsdk | 2 +- 14 files changed, 758 insertions(+), 83 deletions(-) create mode 100644 assets/fonts/AbsIcons.ttf create mode 100644 lib/api/library_provider.dart create mode 100644 lib/api/library_provider.g.dart create mode 100644 lib/features/you/view/widgets/library_switch_chip.dart create mode 100644 lib/shared/icons/abs_icons.dart create mode 100644 lib/shared/widgets/vaani_logo.dart diff --git a/assets/fonts/AbsIcons.ttf b/assets/fonts/AbsIcons.ttf new file mode 100644 index 0000000000000000000000000000000000000000..6f970dc4ffae185e927a5537cacb785bfbd84d56 GIT binary patch literal 5972 zcmd^DeQaCTb-(vL@lpH~zZr=V#Sf8`^+Af?`eba&H6{65l4UP;q|g#&NtQ&Kq%Aw0 zlD239dkdOmTfN0ew*pS(ZB32{Dfo zEHIxdmVWVjw|-8D4Kxu6R>F_2EuepP?atb-YhsLZPq9iIaIdWCLQ{49|3NhT$4ufw z&w-}9HToW_iSZMlYYE4=C~PDvc?}#^7*~ylkTpi4uy!@+A}PY{*tvV0=od4kWzs-u zq*l3oGfo%hk7uOOSWgN$)=I&lM3690{M+<7qQG>Ib4+8;$Tht|!W1juh=K?A0cHQ} z7iP%TGWpeul0F{6Uj7(jY$g=8V}2O@e+al)rEjm@k@$zIG?Uo`HQ9UV&20=v!8&0S z*bRWa&>vUly9zFwxdSyc27V2B4z!IJz!Azq*;uxeUFAS|ru?n)>*e=;_2L?|S|()! z_|A3y&tyJ&-d`xj@{krht?LiG2(xmSp$01xh!UDrVSWwK5*^W#I?Qe)CSoQQ2r`ZT ze=l05fU1iSrj;^i28>y*0`vTG6`)&Et^%`oxeCk^%2i;#T&@Ca0GrCdJily|0d^r} zHXnd_NZBa^Y(>h=GQeu2?2-ZYBjtb$up}wZ$N-y?^0#DwbxHYk8DM8p{+SH0I4Qp; z18k2IA_Lp6RNkZ8>1$jYcbj+ee_ms$8L7Egb3k{&+Z+;0Kk)cB672?q&1R^x(aw%tdmedY&#sP6YO7;B zgDv7#Dl6|<8XV>>v%_I-oS*-M)e_OPu<+>yF+VR(af^D3MPK;?yy>i<9hc^iW*gV) zYG+jnM}ykZ*l798(%4{*NHa-uM$8Qov(ch%a41v|6puK}oPv#B`L)?Wf2k4u|0Ba@ z)$6ScnD4GFaBsq9?PL$^6!Sxk7Ffs=2-3Jr!3K7#ml?nfPCJb!k|`Pw(}JY4+UiQVbZ1b!)U`h}IW;vE zIxux0G&MCj89ET6U$!_LmUP}fJnYY>&5aJr;pd|J&3kQiet(^9uX%s;xx@6IAgIXo zLw7Nj0MrR%F_8-}0RomAQ} z+{qnDNPAAvq~xDa&(+Ap>_qe%I+IEFjm_^U*b(~rVDVgW&=F{C^`9G?WOktqY)g}4 z=lrc;bAKpai%vu$6H!xxIaLLTA->wYqc}KN+_9I*?>)GCD5KftXmvCO9NRRRpOHbf6RJml{(NQ23gzo&vd6}WjU@N3ws z9@%n)%#yEQuSTzNeMf?lm$nX{We` zv7MaM*-2RsHXNE1tVRb;?wew~Ak+*Au79=J=j~7TdwtEHn{+0>X}CA(Pbm2K&^Jv1 zlh*Xr-Zr7r*%|7k7tyFQ^@czRZM|PbTfp?qp*XKd__}+C!+N(xXizy@RCb48(9n91 zMz8le)!{aMo%r`U!QyVxw}sVCuU@b5)KiT?aM)EXPL+=8#iR6PV=6u1^$w&{M*5K| z(4ted^mPv}oPBcP`Eix5#jhGXJ@L+=-e9QdFvx(aDHQBI^v=ZTL6sk}~K9e?8V%9%amwVH{2kFTt(jvg^`w#o4) zR#qODnZLbqW_9d{@j*O()Zyq1E(SXtC{?5~`@)`3yGCuX1S8vD7>)+bCY`>cYlMF7 z>$|$zb@2A~;I^-)yE+VYb%u`c&|kl>C)~l<9bMaB7>aZnP&y+c(k&9_;A4IaHv=;n zgm%2fC`)lMzgtdxebktgQml(<%xbX%;Mqn&>6g=~q)w|v=?@rF9I|nO)~8Z9u=G2a zPnUXs$Kc75_;00?gFBqg$ia9hP@7B~o%)xlqlsi~AQ+nnJDt=ej+k_Gg&lM8yv{`5 zNS$LHSl4qC2cvz-M^j4r7ealTKR0zW-rXHPI+g46)(hf3Wok0f7d^;qjeC={mr>#Z z5jD!GgC&fJ&2CiONAs_w`m(39y}dL2!TpFJjOgo|>F?a%%#T*WZ7hNu9BQk#(c?L> zX@lf4Y%bY9KZbv5Nj*F(j2YKMA;O|>GrC!Jn{Sn z&fWCYSDtO~`5L&B4K26D^P-mC;(DYtNRbUoljB?4kofPDOi+A>y&rvvz9C*?-!F2$ zx(UBGkRTzxyFnXSNebh>_0;5rNu{)eBjt_D>4;w6n1Cw!X3os?rQGhq&Q;pIy0hSR zUqUlmJX;4_c-!sC{u!pc$;otTU~lb{snnCTdk0eKNv11lE&MC+F)O|uaE@^iiY20M z)ayybNW)#Yi?MrF*d6CjRPupjGQf>io|Pjzce4^^?!+D7uHX6RxFvFpK59a@gw0O?z%}=*)Of{`#RH=)at$Do`Z^dZRIDL zS_MP6pr%$@>jHE#3QUi+ZXtb0O76IsCgT=?=_g`KvH*0C-FZ#wa;!Pm+Ipe>&MySDTHtpJ>i2cH&TqtvQSuOc9sz__ z@7)%b3SBH7;G4)5A%kb>@SY@oN+4?Q)kAOHU)j~}&Ky}q{9HlNpuvW`|h zm53|tEiH`S($aoK5)nU=_*XaNWfXhdh$D7FRMe}KqI|-xqBa%+>tG^k=DsgpyK>dN zrL{ZM+baGIeaS*;o!X$kO?y(jMZ9*qRxPMFUL`KlZJh&M-K_!F)ho1z{k^j>KEBpe z)Xh^jr7Pljx^kO8fup{{kBVAR%blbvlI&LypL^Mt8}(_Z!p{2?JpZwH`NfOkw{PR> zN#lL=-6*qV^fU4DrAzd;s(Uzveiq`c+K9OlJ8#S$IPW$)yO6M*#c`M8KG?Nmz~dR% zF_J!;_B8eTzSlk!iwt*s&)47NNuS{@iLT+HZ~~17hf@y+{-}L;WcRL)i-E2E=>uSH z_6sfkiNQYtNz5hRkuf}PjW{`LG=_T)9K#xs7Qkpf3HYCC8E9!47-FyQOuq6|`mI>x z2dBUI^vf5XzcH}&t$n-2PhR`csj2{^==r}&KlRzKXBTpEIpVWhdS;&i?J$S;aZrDE3xy9{ht>7Rhr8cimt7$*~L?(QnoNZ zom&o0=axELv$^F`c5yMssBOoJ!)PduW{;m*%oOgDck794p*Wvgc7=mocUkG|a<-5u zWoOuQ#nZu9*{hcKRGEa_^IZ`4n zd|wPA=DA1&|KmmQt>GfaAmM_fEHzJ-Nro(flOdKF=f=hqW)_$*s3-!#qVYT-1LcTNs-U3WD7OLQl_B5 N(1j9TC>Gg%{|n{hf|~#U literal 0 HcmV?d00001 diff --git a/lib/api/api_provider.dart b/lib/api/api_provider.dart index 0b11499..a991727 100644 --- a/lib/api/api_provider.dart +++ b/lib/api/api_provider.dart @@ -49,8 +49,7 @@ AudiobookshelfApi audiobookshelfApi(Ref ref, Uri? baseUrl) { /// if the user is not authenticated throw an error @Riverpod(keepAlive: true) AudiobookshelfApi authenticatedApi(Ref ref) { - final apiSettings = ref.watch(apiSettingsProvider); - final user = apiSettings.activeUser; + final user = ref.watch(apiSettingsProvider.select((s) => s.activeUser)); if (user == null) { _logger.severe('No active user can not provide authenticated api'); throw StateError('No active user'); diff --git a/lib/api/api_provider.g.dart b/lib/api/api_provider.g.dart index 23c630c..619a729 100644 --- a/lib/api/api_provider.g.dart +++ b/lib/api/api_provider.g.dart @@ -170,7 +170,7 @@ class _AudiobookshelfApiProviderElement Uri? get baseUrl => (origin as AudiobookshelfApiProvider).baseUrl; } -String _$authenticatedApiHash() => r'5cf3329fe3074e3a09e266b4bae78b53e9c01220'; +String _$authenticatedApiHash() => r'284be2c39823c20fb70035a136c430862c28fa27'; /// get the api instance for the authenticated user /// diff --git a/lib/api/library_provider.dart b/lib/api/library_provider.dart new file mode 100644 index 0000000..0903015 --- /dev/null +++ b/lib/api/library_provider.dart @@ -0,0 +1,58 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart' show Ref; +import 'package:logging/logging.dart' show Logger; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:shelfsdk/audiobookshelf_api.dart' show Library; +import 'package:vaani/api/api_provider.dart' show authenticatedApiProvider; +import 'package:vaani/settings/api_settings_provider.dart' + show apiSettingsProvider; +part 'library_provider.g.dart'; + +final _logger = Logger('LibraryProvider'); + +@riverpod +Future library(Ref ref, String id) async { + final api = ref.watch(authenticatedApiProvider); + final library = await api.libraries.get(libraryId: id); + if (library == null) { + _logger.warning('No library found through id: $id'); + // try to get the library from the list of libraries + final libraries = await ref.watch(librariesProvider.future); + for (final lib in libraries) { + if (lib.id == id) { + return lib; + } + } + _logger.warning('No library found in the list of libraries'); + return null; + } + _logger.fine('Fetched library: ${library}'); + return library.library; +} + +@riverpod +Future currentLibrary(Ref ref) async { + final libraryId = + ref.watch(apiSettingsProvider.select((s) => s.activeLibraryId)); + if (libraryId == null) { + _logger.warning('No active library id found'); + return null; + } + return await ref.watch(libraryProvider(libraryId).future); +} + +@riverpod +class Libraries extends _$Libraries { + @override + FutureOr> build() async { + final api = ref.watch(authenticatedApiProvider); + final libraries = await api.libraries.getAll(); + if (libraries == null) { + _logger.warning('Failed to fetch libraries'); + return []; + } + _logger.fine('Fetched ${libraries.length} libraries'); + ref.keepAlive(); + return libraries; + } +} diff --git a/lib/api/library_provider.g.dart b/lib/api/library_provider.g.dart new file mode 100644 index 0000000..8f22251 --- /dev/null +++ b/lib/api/library_provider.g.dart @@ -0,0 +1,192 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'library_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$libraryHash() => r'b62d976f8ab83b2f5823a0fb7dac52fde8fcbffc'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [library]. +@ProviderFor(library) +const libraryProvider = LibraryFamily(); + +/// See also [library]. +class LibraryFamily extends Family> { + /// See also [library]. + const LibraryFamily(); + + /// See also [library]. + LibraryProvider call( + String id, + ) { + return LibraryProvider( + id, + ); + } + + @override + LibraryProvider getProviderOverride( + covariant LibraryProvider provider, + ) { + return call( + provider.id, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'libraryProvider'; +} + +/// See also [library]. +class LibraryProvider extends AutoDisposeFutureProvider { + /// See also [library]. + LibraryProvider( + String id, + ) : this._internal( + (ref) => library( + ref as LibraryRef, + id, + ), + from: libraryProvider, + name: r'libraryProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$libraryHash, + dependencies: LibraryFamily._dependencies, + allTransitiveDependencies: LibraryFamily._allTransitiveDependencies, + id: id, + ); + + LibraryProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.id, + }) : super.internal(); + + final String id; + + @override + Override overrideWith( + FutureOr Function(LibraryRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: LibraryProvider._internal( + (ref) => create(ref as LibraryRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + id: id, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _LibraryProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is LibraryProvider && other.id == id; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, id.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin LibraryRef on AutoDisposeFutureProviderRef { + /// The parameter `id` of this provider. + String get id; +} + +class _LibraryProviderElement extends AutoDisposeFutureProviderElement + with LibraryRef { + _LibraryProviderElement(super.provider); + + @override + String get id => (origin as LibraryProvider).id; +} + +String _$currentLibraryHash() => r'658498a531e04a01e2b3915a3319101285601118'; + +/// See also [currentLibrary]. +@ProviderFor(currentLibrary) +final currentLibraryProvider = AutoDisposeFutureProvider.internal( + currentLibrary, + name: r'currentLibraryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$currentLibraryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef CurrentLibraryRef = AutoDisposeFutureProviderRef; +String _$librariesHash() => r'95ebd4d1ac0cc2acf7617dc22895eff0ca30600f'; + +/// See also [Libraries]. +@ProviderFor(Libraries) +final librariesProvider = + AutoDisposeAsyncNotifierProvider>.internal( + Libraries.new, + name: r'librariesProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$librariesHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$Libraries = AutoDisposeAsyncNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/library_browser/view/library_browser_page.dart b/lib/features/library_browser/view/library_browser_page.dart index d12de57..4327b17 100644 --- a/lib/features/library_browser/view/library_browser_page.dart +++ b/lib/features/library_browser/view/library_browser_page.dart @@ -1,47 +1,83 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:vaani/router/router.dart'; +import 'package:vaani/api/library_provider.dart' show currentLibraryProvider; +import 'package:vaani/features/you/view/widgets/library_switch_chip.dart' + show showLibrarySwitcher; +import 'package:vaani/router/router.dart' show Routes; +import 'package:vaani/shared/icons/abs_icons.dart' show AbsIcons; +import 'package:vaani/shared/widgets/not_implemented.dart' + show showNotImplementedToast; class LibraryBrowserPage extends HookConsumerWidget { const LibraryBrowserPage({super.key}); - @override Widget build(BuildContext context, WidgetRef ref) { + final currentLibrary = ref.watch(currentLibraryProvider).valueOrNull; + + // Determine the icon to use, with a fallback + final IconData libraryIconData = + AbsIcons.getIconByName(currentLibrary?.icon) ?? Icons.library_books; + + // Determine the title text + final String appBarTitle = '${currentLibrary?.name ?? 'Your'} Library'; + return Scaffold( - appBar: AppBar( - title: const Text('Library'), - backgroundColor: Colors.transparent, - ), - // a list redirecting to authors, genres, and series pages - body: ListView( - children: [ - ListTile( - title: const Text('Authors'), - leading: const Icon(Icons.person), - trailing: const Icon(Icons.chevron_right), - onTap: () {}, + // Use CustomScrollView to enable slivers + body: CustomScrollView( + slivers: [ + SliverAppBar( + pinned: true, + // floating: true, // Optional: uncomment if you want floating behavior + // snap: + // true, // Optional: uncomment if you want snapping behavior (usually with floating: true) + leading: IconButton( + icon: Icon(libraryIconData), + tooltip: 'Switch Library', // Helpful tooltip for users + onPressed: () { + showLibrarySwitcher(context, ref); + }, + ), + title: Text(appBarTitle), ), - ListTile( - title: const Text('Genres'), - leading: const Icon(Icons.category), - trailing: const Icon(Icons.chevron_right), - onTap: () {}, - ), - ListTile( - title: const Text('Series'), - leading: const Icon(Icons.list), - trailing: const Icon(Icons.chevron_right), - onTap: () {}, - ), - // Downloads - ListTile( - title: const Text('Downloads'), - leading: const Icon(Icons.download), - trailing: const Icon(Icons.chevron_right), - onTap: () { - GoRouter.of(context).pushNamed(Routes.downloads.name); - }, + SliverList( + delegate: SliverChildListDelegate( + [ + ListTile( + title: const Text('Authors'), + leading: const Icon(Icons.person), + trailing: const Icon(Icons.chevron_right), + onTap: () { + showNotImplementedToast(context); + }, + ), + ListTile( + title: const Text('Genres'), + leading: const Icon(Icons.category), + trailing: const Icon(Icons.chevron_right), + onTap: () { + showNotImplementedToast(context); + }, + ), + ListTile( + title: const Text('Series'), + leading: const Icon(Icons.list), + trailing: const Icon(Icons.chevron_right), + onTap: () { + showNotImplementedToast(context); + }, + ), + // Downloads + ListTile( + title: const Text('Downloads'), + leading: const Icon(Icons.download), + trailing: const Icon(Icons.chevron_right), + onTap: () { + GoRouter.of(context).pushNamed(Routes.downloads.name); + }, + ), + ], + ), ), ], ), diff --git a/lib/features/logging/view/logs_page.dart b/lib/features/logging/view/logs_page.dart index ad2c764..74d1ad3 100644 --- a/lib/features/logging/view/logs_page.dart +++ b/lib/features/logging/view/logs_page.dart @@ -1,15 +1,10 @@ -import 'dart:io'; - -import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:logging/logging.dart'; -import 'package:permission_handler/permission_handler.dart'; import 'package:share_plus/share_plus.dart'; import 'package:vaani/features/logging/providers/logs_provider.dart'; import 'package:vaani/main.dart'; -import 'package:vaani/settings/metadata/metadata_provider.dart'; class LogsPage extends HookConsumerWidget { const LogsPage({super.key}); diff --git a/lib/features/you/view/widgets/library_switch_chip.dart b/lib/features/you/view/widgets/library_switch_chip.dart new file mode 100644 index 0000000..1463b8f --- /dev/null +++ b/lib/features/you/view/widgets/library_switch_chip.dart @@ -0,0 +1,224 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shelfsdk/audiobookshelf_api.dart' show Library; +import 'package:vaani/api/library_provider.dart'; +import 'package:vaani/settings/api_settings_provider.dart' + show apiSettingsProvider; +import 'package:vaani/shared/icons/abs_icons.dart'; +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; +import 'package:vaani/main.dart' show appLogger; + +class LibrarySwitchChip extends HookConsumerWidget { + const LibrarySwitchChip({ + super.key, + required this.libraries, + }); + final List libraries; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final apiSettings = ref.watch(apiSettingsProvider); + + return ActionChip( + avatar: Icon( + AbsIcons.getIconByName( + apiSettings.activeLibraryId != null + ? libraries + .firstWhere( + (lib) => lib.id == apiSettings.activeLibraryId, + ) + .icon + : libraries.first.icon, + ), + ), // Replace with your icon + label: const Text('Change Library'), + // Enable only if libraries are loaded and not empty + onPressed: libraries.isNotEmpty + ? () => showLibrarySwitcher( + context, + ref, + ) + : null, // Disable if no libraries + ); + } +} + +// --- Helper Function to Show the Switcher --- +void showLibrarySwitcher( + BuildContext context, + WidgetRef ref, +) { + final content = _LibrarySelectionContent(); + + // --- Platform-Specific UI --- + bool isDesktop = false; + if (!kIsWeb) { + // dart:io Platform is not available on web + isDesktop = Platform.isLinux || Platform.isMacOS || Platform.isWindows; + } else { + // Basic web detection (might need refinement based on screen size) + // Consider using MediaQuery for a size-based check instead for web/tablet + final size = MediaQuery.of(context).size; + isDesktop = size.width > 600; // Example threshold for "desktop-like" layout + } + + if (isDesktop) { + // --- Desktop: Use AlertDialog --- + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Select Library'), + content: SizedBox( + // Constrain size for dialogs + width: 300, // Adjust as needed + // Make content scrollable if list is long + child: Scrollbar(child: content), + ), + actions: [ + TextButton( + onPressed: () { + // Invalidate the provider to trigger a refetch + ref.invalidate(librariesProvider); + Navigator.pop(dialogContext); + }, + child: const Text('Refresh'), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + ], + ), + ); + } else { + // --- Mobile/Tablet: Use BottomSheet --- + showModalBottomSheet( + context: context, + // Make it scrollable and control height + isScrollControlled: true, + constraints: BoxConstraints( + maxHeight: + MediaQuery.of(context).size.height * 0.6, // Max 60% of screen + ), + builder: (sheetContext) => Padding( + // Add padding within the bottom sheet + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, // Take minimum necessary height + children: [ + const Text( + 'Select Library', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 10), + const Divider(), + Flexible( + // Allow the list to take remaining space and scroll + child: Scrollbar(child: content), + ), + const SizedBox(height: 10), + ElevatedButton.icon( + icon: const Icon(Icons.refresh), + label: const Text('Refresh'), + onPressed: () { + // Invalidate the provider to trigger a refetch + ref.invalidate(librariesProvider); + }, + ), + ], + ), + ), + ); + } +} + +// --- Widget for the Selection List Content (Reusable) --- +class _LibrarySelectionContent extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final librariesAsyncValue = ref.watch(librariesProvider); + final currentLibraryId = ref.watch( + apiSettingsProvider.select((settings) => settings.activeLibraryId), + ); + final errorColor = Theme.of(context).colorScheme.error; + return librariesAsyncValue.when( + // --- Loading State --- + loading: () => const Center(child: CircularProgressIndicator()), + + // --- Error State --- + error: (error, stackTrace) => Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, color: errorColor), + const SizedBox(height: 10), + Text( + 'Error loading libraries: $error', + textAlign: TextAlign.center, + style: TextStyle(color: errorColor), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + onPressed: () { + // Invalidate the provider to trigger a refetch + ref.invalidate(librariesProvider); + }, + ), + ], + ), + ), + ), + + // --- Data State --- + data: (libraries) { + // Handle case where data loaded successfully but is empty + if (libraries.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Text('No libraries available.'), + ), + ); + } + + // Build the list if libraries are available + return Scrollbar( + // Add scrollbar for potentially long lists + child: ListView.builder( + shrinkWrap: true, // Important for Dialog/BottomSheet sizing + itemCount: libraries.length, + itemBuilder: (context, index) { + final library = libraries[index]; + final bool isSelected = library.id == currentLibraryId; + + return ListTile( + title: Text(library.name), + leading: Icon(AbsIcons.getIconByName(library.icon)), + selected: isSelected, + trailing: isSelected ? const Icon(Icons.check) : null, + onTap: () { + appLogger.info( + 'Selected library: ${library.name} (ID: ${library.id})'); + // Get current settings state + final currentSettings = ref.read(apiSettingsProvider); + // Update the active library ID + ref.read(apiSettingsProvider.notifier).updateState( + currentSettings.copyWith(activeLibraryId: library.id), + ); + // Close the dialog/bottom sheet + Navigator.pop(context); + }, + ); + }, + ), + ); + }, + ); + } +} diff --git a/lib/features/you/view/you_page.dart b/lib/features/you/view/you_page.dart index c15e80f..cfe15cd 100644 --- a/lib/features/you/view/you_page.dart +++ b/lib/features/you/view/you_page.dart @@ -2,11 +2,14 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:vaani/api/api_provider.dart'; +import 'package:vaani/api/library_provider.dart' show librariesProvider; import 'package:vaani/features/player/view/mini_player_bottom_padding.dart'; +import 'package:vaani/features/you/view/widgets/library_switch_chip.dart'; import 'package:vaani/router/router.dart'; import 'package:vaani/settings/constants.dart'; import 'package:vaani/shared/utils.dart'; import 'package:vaani/shared/widgets/not_implemented.dart'; +import 'package:vaani/shared/widgets/vaani_logo.dart'; class YouPage extends HookConsumerWidget { const YouPage({ @@ -16,6 +19,7 @@ class YouPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final api = ref.watch(authenticatedApiProvider); + final librariesAsyncValue = ref.watch(librariesProvider); return Scaffold( appBar: AppBar( // title: const Text('You'), @@ -63,7 +67,35 @@ class YouPage extends HookConsumerWidget { context.pushNamed(Routes.userManagement.name); }, ), - // ActionChip( + librariesAsyncValue.when( + data: (libraries) => + LibrarySwitchChip(libraries: libraries), + loading: () => const ActionChip( + avatar: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + label: Text('Loading Libs...'), + onPressed: null, // Disable while loading + ), + error: (error, stack) => ActionChip( + avatar: Icon( + Icons.error_outline, + color: Theme.of(context).colorScheme.error, + ), + label: const Text('Error Loading Libs'), + onPressed: () { + // Maybe show error details or allow retry + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text('Failed to load libraries: $error'), + ), + ); + }, + ), + ), // ActionChip( // avatar: const Icon(Icons.logout), // label: const Text('Logout'), // onPressed: () { @@ -185,29 +217,3 @@ class UserBar extends HookConsumerWidget { ); } } - -class VaaniLogo extends StatelessWidget { - const VaaniLogo({ - super.key, - this.size, - this.duration = const Duration(milliseconds: 750), - this.curve = Curves.fastOutSlowIn, - }); - - final double? size; - final Duration duration; - final Curve curve; - - @override - Widget build(BuildContext context) { - final IconThemeData iconTheme = IconTheme.of(context); - final double? iconSize = size ?? iconTheme.size; - return AnimatedContainer( - width: iconSize, - height: iconSize, - duration: duration, - curve: curve, - child: Image.asset('assets/images/vaani_logo_foreground.png'), - ); - } -} diff --git a/lib/router/scaffold_with_nav_bar.dart b/lib/router/scaffold_with_nav_bar.dart index 0a89162..27c3355 100644 --- a/lib/router/scaffold_with_nav_bar.dart +++ b/lib/router/scaffold_with_nav_bar.dart @@ -2,12 +2,15 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:miniplayer/miniplayer.dart'; +import 'package:vaani/api/library_provider.dart' show currentLibraryProvider; import 'package:vaani/features/explore/providers/search_controller.dart'; import 'package:vaani/features/player/providers/player_form.dart'; import 'package:vaani/features/player/view/audiobook_player.dart'; import 'package:vaani/features/player/view/player_when_expanded.dart'; +import 'package:vaani/features/you/view/widgets/library_switch_chip.dart'; import 'package:vaani/main.dart'; import 'package:vaani/router/router.dart'; +import 'package:vaani/shared/icons/abs_icons.dart' show AbsIcons; // stack to track changes in navigationShell.currentIndex // home is always at index 0 and at the start and should be the last before popping @@ -111,17 +114,39 @@ class ScaffoldWithNavBar extends HookConsumerWidget { // world scenario, the items would most likely be generated from the // branches of the shell route, which can be fetched using // `navigationShell.route.branches`. - destinations: _navigationItems - .map( - (item) => NavigationDestination( - icon: Icon(item.icon), - selectedIcon: item.activeIcon != null - ? Icon(item.activeIcon) - : Icon(item.icon), - label: item.name, - ), - ) - .toList(), + destinations: _navigationItems.map((item) { + final isDestinationLibrary = item.name == 'Library'; + var currentLibrary = + ref.watch(currentLibraryProvider).valueOrNull; + final libraryIcon = AbsIcons.getIconByName( + currentLibrary?.icon, + ); + final destinationWidget = NavigationDestination( + icon: Icon( + isDestinationLibrary ? libraryIcon ?? item.icon : item.icon, + ), + selectedIcon: Icon( + isDestinationLibrary + ? libraryIcon ?? item.activeIcon + : item.activeIcon, + ), + label: isDestinationLibrary + ? currentLibrary?.name ?? item.name + : item.name, + tooltip: item.tooltip, + ); + if (isDestinationLibrary) { + return GestureDetector( + onSecondaryTap: () => showLibrarySwitcher(context, ref), + onDoubleTap: () => showLibrarySwitcher(context, ref), + child: + destinationWidget, // Wrap the actual NavigationDestination + ); + } else { + // Return the unwrapped destination for other items + return destinationWidget; + } + }).toList(), selectedIndex: navigationShell.currentIndex, onDestinationSelected: (int index) => _onTap(context, index, ref), ), @@ -191,16 +216,19 @@ const _navigationItems = [ name: 'Library', icon: Icons.book_outlined, activeIcon: Icons.book, + tooltip: 'Browse your library', ), _NavigationItem( name: 'Explore', icon: Icons.search_outlined, activeIcon: Icons.search, + tooltip: 'Search and Explore', ), _NavigationItem( name: 'You', icon: Icons.account_circle_outlined, activeIcon: Icons.account_circle, + tooltip: 'Your Profile and Settings', ), ]; @@ -208,10 +236,12 @@ class _NavigationItem { const _NavigationItem({ required this.name, required this.icon, - this.activeIcon, + required this.activeIcon, + this.tooltip, }); final String name; final IconData icon; - final IconData? activeIcon; + final IconData activeIcon; + final String? tooltip; } diff --git a/lib/shared/icons/abs_icons.dart b/lib/shared/icons/abs_icons.dart new file mode 100644 index 0000000..eca3e1a --- /dev/null +++ b/lib/shared/icons/abs_icons.dart @@ -0,0 +1,103 @@ +/// Flutter icons AbsIcons +/// Copyright (C) 2025 by original authors @ fluttericon.com, fontello.com +/// This font was generated by FlutterIcon.com, which is derived from Fontello. +/// +/// To use this font, place it in your fonts/ directory and include the +/// following in your pubspec.yaml +/// +/// flutter: +/// fonts: +/// - family: AbsIcons +/// fonts: +/// - asset: fonts/AbsIcons.ttf +/// +/// +/// +library; +// ignore_for_file: constant_identifier_names + +import 'package:flutter/widgets.dart' show IconData; + +class AbsIcons { + AbsIcons._(); + + static const _kFontFam = 'AbsIcons'; + static const String? _kFontPkg = null; + + static const IconData audiobookshelf = + IconData(0xe900, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData microphone_2 = + IconData(0xe901, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData microphone_1 = + IconData(0xe902, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData radio = + IconData(0xe903, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData podcast = + IconData(0xe904, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData books_1 = + IconData(0xe905, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData database_2 = + IconData(0xe906, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData headphones = + IconData(0xe910, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData music = + IconData(0xe911, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData video = + IconData(0xe914, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData microphone_3 = + IconData(0xe91e, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData book = + IconData(0xe91f, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData books_2 = + IconData(0xe920, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData file_picture = + IconData(0xe927, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData database_1 = + IconData(0xe964, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData rocket = + IconData(0xe9a5, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData power = + IconData(0xe9b5, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData star = + IconData(0xe9d9, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData heart = + IconData(0xe9da, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData rss = + IconData(0xea9b, fontFamily: _kFontFam, fontPackage: _kFontPkg); + + static final Map _iconMap = { + 'audiobookshelf': audiobookshelf, + 'microphone_2': microphone_2, + 'microphone_1': microphone_1, + 'radio': radio, + 'podcast': podcast, + 'books_1': books_1, + 'database_2': database_2, + 'headphones': headphones, + 'music': music, + 'video': video, + 'microphone_3': microphone_3, + 'book': book, + 'books_2': books_2, + 'file_picture': file_picture, + 'database_1': database_1, + 'rocket': rocket, + 'power': power, + 'star': star, + 'heart': heart, + 'rss': rss, + }; + + /// Returns the IconData corresponding to the [iconName] string. + /// + /// If the [iconName] is not found in the map, returns null. + /// Considers null or empty strings as invalid. + static IconData? getIconByName(String? iconName) { + if (iconName == null || iconName.isEmpty) { + return null; + } + return _iconMap[iconName.toLowerCase()]; + } + + static Map get iconMap => _iconMap; +} diff --git a/lib/shared/widgets/vaani_logo.dart b/lib/shared/widgets/vaani_logo.dart new file mode 100644 index 0000000..a5b4e11 --- /dev/null +++ b/lib/shared/widgets/vaani_logo.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +class VaaniLogo extends StatelessWidget { + const VaaniLogo({ + super.key, + this.size, + this.duration = const Duration(milliseconds: 750), + this.curve = Curves.fastOutSlowIn, + }); + + final double? size; + final Duration duration; + final Curve curve; + + @override + Widget build(BuildContext context) { + final IconThemeData iconTheme = IconTheme.of(context); + final double? iconSize = size ?? iconTheme.size; + return AnimatedContainer( + width: iconSize, + height: iconSize, + duration: duration, + curve: curve, + child: Image.asset('assets/images/vaani_logo_foreground.png'), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index e1f9c4d..2aafa66 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -122,6 +122,7 @@ flutter: - assets/animations/ - assets/sounds/ - assets/images/ + - assets/fonts/ # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg # An image asset can refer to one or more resolution-specific "variants", see @@ -147,3 +148,7 @@ flutter: # # For details regarding fonts from package dependencies, # see https://flutter.dev/custom-fonts/#from-packages + fonts: + - family: AbsIcons + fonts: + - asset: assets/fonts/AbsIcons.ttf diff --git a/shelfsdk b/shelfsdk index 5cc545c..e1848a4 160000 --- a/shelfsdk +++ b/shelfsdk @@ -1 +1 @@ -Subproject commit 5cc545ca87c05615473ab9c363cfa29e341d1e2a +Subproject commit e1848a42c27257146015a33e9427f197f522fe03