diff --git a/lib/extensions/hero_tag_conventions.dart b/lib/extensions/hero_tag_conventions.dart new file mode 100644 index 0000000..2605bd7 --- /dev/null +++ b/lib/extensions/hero_tag_conventions.dart @@ -0,0 +1,12 @@ +class HeroTagPrefixes { + static const String heroTagPrefix = 'hero_tag_'; + + /// The hero tag for the book cover + static const String bookCover = 'book_cover_'; + static const String bookCoverSkeleton = 'book_cover_skeleton_'; + static const String authorAvatar = 'author_avatar_'; + static const String authorAvatarSkeleton = 'author_avatar_skeleton_'; + static const String authorName = 'author_name_'; + static const String bookTitle = 'book_title_'; + static const String narratorName = 'narrator_name_'; +} diff --git a/lib/main.dart b/lib/main.dart index 7743a22..4f54531 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,8 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:whispering_pages/api/server_provider.dart'; import 'package:whispering_pages/db/storage.dart'; -import 'package:whispering_pages/pages/onboarding/onboarding.dart'; -import 'package:whispering_pages/pages/pages.dart'; +import 'package:whispering_pages/router/router.dart'; import 'package:whispering_pages/settings/api_settings_provider.dart'; import 'package:whispering_pages/settings/app_settings_provider.dart'; import 'package:whispering_pages/theme/theme.dart'; @@ -32,13 +31,13 @@ class MyApp extends ConsumerWidget { bool needOnboarding() { return apiSettings.activeUser == null || servers.isEmpty; } - return MaterialApp( + return MaterialApp.router( theme: lightTheme, darkTheme: darkTheme, themeMode: ref.watch(appSettingsProvider).isDarkMode ? ThemeMode.dark : ThemeMode.light, - home: needOnboarding() ? const OnboardingPage() : const HomePage(), + routerConfig: MyAppRouter(needOnboarding: needOnboarding()).config, ); } } diff --git a/lib/pages/library_item_page.dart b/lib/pages/library_item_page.dart new file mode 100644 index 0000000..f8de013 --- /dev/null +++ b/lib/pages/library_item_page.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:whispering_pages/api/api_provider.dart'; +import 'package:whispering_pages/extensions/hero_tag_conventions.dart'; +import 'package:whispering_pages/router/models/library_item_extras.dart'; + +class LibraryItemPage extends HookConsumerWidget { + const LibraryItemPage({ + super.key, + required this.itemId, + this.extra, + }); + + final String itemId; + final Object? extra; + @override + Widget build(BuildContext context, WidgetRef ref) { + final views = ref.watch(personalizedViewProvider); + final extraMap = + extra is LibraryItemExtras ? extra as LibraryItemExtras : null; + + return Scaffold( + appBar: AppBar(), + body: Center( + child: Hero( + tag: HeroTagPrefixes.bookCover + + itemId + + (extraMap?.heroTagSuffix ?? ''), + child: Container( + color: Colors.amber, + height: 200, + width: 200, + ), + ), + ), + ); + } +} diff --git a/lib/pages/library_page.dart b/lib/pages/library_page.dart new file mode 100644 index 0000000..293e6fa --- /dev/null +++ b/lib/pages/library_page.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:whispering_pages/api/api_provider.dart'; +import 'package:whispering_pages/settings/api_settings_provider.dart'; + +import '../widgets/drawer.dart'; +import '../widgets/shelves/home_shelf.dart'; + +// TODO: implement the library page +class LibraryPage extends HookConsumerWidget { + const LibraryPage({this.libraryId, super.key}); + + final String? libraryId; + @override + Widget build(BuildContext context, WidgetRef ref) { + // set the library id as the active library + if (libraryId != null) { + ref.read(apiSettingsProvider.notifier).updateState( + ref.watch(apiSettingsProvider).copyWith(activeLibraryId: libraryId), + ); + } + + final views = ref.watch(personalizedViewProvider); + final scrollController = useScrollController(); + + return Scaffold( + appBar: AppBar( + title: GestureDetector( + child: const Text('Whispering Pages'), + onTap: () { + // scroll to the top of the page + scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + // refresh the view + ref.invalidate(personalizedViewProvider); + }, + ), + ), + drawer: const MyDrawer(), + body: Container( + child: views.when( + data: (data) { + final shelvesToDisplay = data + // .where((element) => !element.id.contains('discover')) + .map((shelf) { + debugPrint('building shelf ${shelf.label}'); + return HomeShelf( + title: shelf.label, + shelf: shelf, + ); + }).toList(); + return RefreshIndicator( + onRefresh: () async { + return ref.refresh(personalizedViewProvider); + }, + child: ListView.separated( + itemBuilder: (context, index) => shelvesToDisplay[index], + separatorBuilder: (context, index) => Divider( + color: Theme.of(context).dividerColor.withOpacity(0.1), + indent: 16, + endIndent: 16, + ), + itemCount: shelvesToDisplay.length, + controller: scrollController, + ), + ); + }, + loading: () => const LibraryPageSkeleton(), + error: (error, stack) { + return Text('Error: $error'); + }, + ), + ), + ); + } +} + +class LibraryPageSkeleton extends StatelessWidget { + const LibraryPageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); + } +} diff --git a/lib/pages/pages.dart b/lib/pages/pages.dart deleted file mode 100644 index fa05138..0000000 --- a/lib/pages/pages.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'home_page.dart'; -export 'server_manager.dart'; diff --git a/lib/router/constants.dart b/lib/router/constants.dart new file mode 100644 index 0000000..170f4a2 --- /dev/null +++ b/lib/router/constants.dart @@ -0,0 +1,34 @@ +// to store names of routes + +part of 'router.dart'; + +class Routes { + static const home = 'home'; + static const onboarding = 'onboarding'; + static const library = _SimpleRoute( + pathName: 'library', + pathParamName: 'libraryId', + name: 'library', + ); + static const libraryItem = _SimpleRoute( + pathName: 'item', + pathParamName: 'itemId', + name: 'libraryItem', + ); +} + +// a class to store path + +class _SimpleRoute { + const _SimpleRoute({ + required this.pathName, + required this.pathParamName, + required this.name, + }); + + final String pathName; + final String pathParamName; + final String name; + + String get path => '/$pathName/:$pathParamName'; +} diff --git a/lib/router/models/library_item_extras.dart b/lib/router/models/library_item_extras.dart new file mode 100644 index 0000000..d77fa60 --- /dev/null +++ b/lib/router/models/library_item_extras.dart @@ -0,0 +1,23 @@ +// a freezed class to store the settings of the app + +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:shelfsdk/audiobookshelf_api.dart'; + +part 'library_item_extras.freezed.dart'; +part 'library_item_extras.g.dart'; + +/// any extras when navigating to a library item +/// +/// [shelfId] is the id of the shelf that the item was on before navigating to the item +/// [book] is the book that the item represents +/// [heroTagSuffix] is the suffix to use for the hero tag to avoid conflicts +@freezed +class LibraryItemExtras with _$LibraryItemExtras { + const factory LibraryItemExtras({ + BookMinified? book, + String? heroTagSuffix, + }) = _LibraryItemExtras; + + factory LibraryItemExtras.fromJson(Map json) => + _$LibraryItemExtrasFromJson(json); +} diff --git a/lib/router/models/library_item_extras.freezed.dart b/lib/router/models/library_item_extras.freezed.dart new file mode 100644 index 0000000..e1ee3b8 --- /dev/null +++ b/lib/router/models/library_item_extras.freezed.dart @@ -0,0 +1,171 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'library_item_extras.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +LibraryItemExtras _$LibraryItemExtrasFromJson(Map json) { + return _LibraryItemExtras.fromJson(json); +} + +/// @nodoc +mixin _$LibraryItemExtras { + BookMinified? get book => throw _privateConstructorUsedError; + String? get heroTagSuffix => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $LibraryItemExtrasCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $LibraryItemExtrasCopyWith<$Res> { + factory $LibraryItemExtrasCopyWith( + LibraryItemExtras value, $Res Function(LibraryItemExtras) then) = + _$LibraryItemExtrasCopyWithImpl<$Res, LibraryItemExtras>; + @useResult + $Res call({BookMinified? book, String? heroTagSuffix}); +} + +/// @nodoc +class _$LibraryItemExtrasCopyWithImpl<$Res, $Val extends LibraryItemExtras> + implements $LibraryItemExtrasCopyWith<$Res> { + _$LibraryItemExtrasCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? book = freezed, + Object? heroTagSuffix = freezed, + }) { + return _then(_value.copyWith( + book: freezed == book + ? _value.book + : book // ignore: cast_nullable_to_non_nullable + as BookMinified?, + heroTagSuffix: freezed == heroTagSuffix + ? _value.heroTagSuffix + : heroTagSuffix // ignore: cast_nullable_to_non_nullable + as String?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$LibraryItemExtrasImplCopyWith<$Res> + implements $LibraryItemExtrasCopyWith<$Res> { + factory _$$LibraryItemExtrasImplCopyWith(_$LibraryItemExtrasImpl value, + $Res Function(_$LibraryItemExtrasImpl) then) = + __$$LibraryItemExtrasImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({BookMinified? book, String? heroTagSuffix}); +} + +/// @nodoc +class __$$LibraryItemExtrasImplCopyWithImpl<$Res> + extends _$LibraryItemExtrasCopyWithImpl<$Res, _$LibraryItemExtrasImpl> + implements _$$LibraryItemExtrasImplCopyWith<$Res> { + __$$LibraryItemExtrasImplCopyWithImpl(_$LibraryItemExtrasImpl _value, + $Res Function(_$LibraryItemExtrasImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? book = freezed, + Object? heroTagSuffix = freezed, + }) { + return _then(_$LibraryItemExtrasImpl( + book: freezed == book + ? _value.book + : book // ignore: cast_nullable_to_non_nullable + as BookMinified?, + heroTagSuffix: freezed == heroTagSuffix + ? _value.heroTagSuffix + : heroTagSuffix // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$LibraryItemExtrasImpl implements _LibraryItemExtras { + const _$LibraryItemExtrasImpl({this.book, this.heroTagSuffix}); + + factory _$LibraryItemExtrasImpl.fromJson(Map json) => + _$$LibraryItemExtrasImplFromJson(json); + + @override + final BookMinified? book; + @override + final String? heroTagSuffix; + + @override + String toString() { + return 'LibraryItemExtras(book: $book, heroTagSuffix: $heroTagSuffix)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$LibraryItemExtrasImpl && + (identical(other.book, book) || other.book == book) && + (identical(other.heroTagSuffix, heroTagSuffix) || + other.heroTagSuffix == heroTagSuffix)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, book, heroTagSuffix); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$LibraryItemExtrasImplCopyWith<_$LibraryItemExtrasImpl> get copyWith => + __$$LibraryItemExtrasImplCopyWithImpl<_$LibraryItemExtrasImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$LibraryItemExtrasImplToJson( + this, + ); + } +} + +abstract class _LibraryItemExtras implements LibraryItemExtras { + const factory _LibraryItemExtras( + {final BookMinified? book, + final String? heroTagSuffix}) = _$LibraryItemExtrasImpl; + + factory _LibraryItemExtras.fromJson(Map json) = + _$LibraryItemExtrasImpl.fromJson; + + @override + BookMinified? get book; + @override + String? get heroTagSuffix; + @override + @JsonKey(ignore: true) + _$$LibraryItemExtrasImplCopyWith<_$LibraryItemExtrasImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/router/models/library_item_extras.g.dart b/lib/router/models/library_item_extras.g.dart new file mode 100644 index 0000000..aa9b0d3 --- /dev/null +++ b/lib/router/models/library_item_extras.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'library_item_extras.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$LibraryItemExtrasImpl _$$LibraryItemExtrasImplFromJson( + Map json) => + _$LibraryItemExtrasImpl( + book: json['book'] == null + ? null + : BookMinified.fromJson(json['book'] as Map), + heroTagSuffix: json['heroTagSuffix'] as String?, + ); + +Map _$$LibraryItemExtrasImplToJson( + _$LibraryItemExtrasImpl instance) => + { + 'book': instance.book, + 'heroTagSuffix': instance.heroTagSuffix, + }; diff --git a/lib/router/router.dart b/lib/router/router.dart new file mode 100644 index 0000000..ecd47f3 --- /dev/null +++ b/lib/router/router.dart @@ -0,0 +1,52 @@ +import 'package:go_router/go_router.dart'; +import 'package:whispering_pages/pages/home_page.dart'; +import 'package:whispering_pages/pages/library_item_page.dart'; +import 'package:whispering_pages/pages/library_page.dart'; +import 'package:whispering_pages/pages/onboarding/onboarding.dart'; + +part 'constants.dart'; + +// GoRouter configuration +class MyAppRouter { + const MyAppRouter({required this.needOnboarding}); + final bool needOnboarding; + // some static strings for named routes + + GoRouter get config => GoRouter( + routes: [ + GoRoute( + path: '/login', + name: Routes.onboarding, + builder: (context, state) => const OnboardingPage(), + ), + GoRoute( + path: '/', + name: Routes.home, + builder: (context, state) => const HomePage(), + ), + // /library/:libraryId + GoRoute( + path: Routes.library.path, + name: Routes.library.name, + builder: (context, state) => LibraryPage( + libraryId: state.pathParameters[Routes.library.pathParamName]!, + ), + ), + GoRoute( + path: Routes.libraryItem.path, + name: Routes.libraryItem.name, + builder: (context, state) { + final itemId = + state.pathParameters[Routes.libraryItem.pathParamName]!; + return LibraryItemPage(itemId: itemId, extra: state.extra); + }, + ), + ], + redirect: (context, state) { + if (needOnboarding) { + return context.namedLocation(Routes.onboarding); + } + return null; + }, + ); +} diff --git a/lib/settings/api_settings_provider.dart b/lib/settings/api_settings_provider.dart index 24d54f6..e2e1ff0 100644 --- a/lib/settings/api_settings_provider.dart +++ b/lib/settings/api_settings_provider.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:whispering_pages/settings/models/api_settings.dart' as model; import 'package:whispering_pages/db/available_boxes.dart'; +import 'package:whispering_pages/settings/models/api_settings.dart' as model; part 'api_settings_provider.g.dart'; @@ -41,7 +41,12 @@ class ApiSettings extends _$ApiSettings { debugPrint('wrote api settings to box: $state'); } - void updateState(model.ApiSettings newSettings) { + void updateState(model.ApiSettings newSettings, {bool force = false}) { + // check if the settings are different + + if (state == newSettings && !force) { + return; + } state = newSettings; } } diff --git a/lib/settings/api_settings_provider.g.dart b/lib/settings/api_settings_provider.g.dart index 39047bc..4b7a856 100644 --- a/lib/settings/api_settings_provider.g.dart +++ b/lib/settings/api_settings_provider.g.dart @@ -6,7 +6,7 @@ part of 'api_settings_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$apiSettingsHash() => r'a6927751bd91ec7c9e1a2810dc939407d9112210'; +String _$apiSettingsHash() => r'f08d87b716b31bfb4040fc6440840ac97b7ee686'; /// See also [ApiSettings]. @ProviderFor(ApiSettings) diff --git a/lib/widgets/shelves/book_shelf.dart b/lib/widgets/shelves/book_shelf.dart index 35b6231..5e3e385 100644 --- a/lib/widgets/shelves/book_shelf.dart +++ b/lib/widgets/shelves/book_shelf.dart @@ -1,10 +1,14 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shelfsdk/audiobookshelf_api.dart'; -import 'package:shimmer/shimmer.dart' show Shimmer, ShimmerDirection; +import 'package:shimmer/shimmer.dart' show Shimmer; import 'package:whispering_pages/api/image_provider.dart'; +import 'package:whispering_pages/extensions/hero_tag_conventions.dart'; +import 'package:whispering_pages/router/models/library_item_extras.dart'; +import 'package:whispering_pages/router/router.dart'; import 'package:whispering_pages/widgets/shelves/home_shelf.dart'; /// A shelf that displays books on the home page @@ -28,6 +32,7 @@ class BookHomeShelf extends HookConsumerWidget { MediaType.book => BookOnShelf( item: item, key: ValueKey(shelf.id + item.id), + heroTagSuffix: shelf.id, ), _ => Container(), }, @@ -42,10 +47,14 @@ class BookOnShelf extends HookConsumerWidget { const BookOnShelf({ super.key, required this.item, + this.heroTagSuffix = '', }); final LibraryItem item; + /// makes the hero tag unique + final String heroTagSuffix; + @override Widget build(BuildContext context, WidgetRef ref) { final book = BookMinified.fromJson(item.media.toJson()); @@ -63,30 +72,49 @@ class BookOnShelf extends HookConsumerWidget { // the cover image of the book // take up remaining space Expanded( - // border radius - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: coverImage.when( - data: (image) { - // return const BookCoverSkeleton(); - if (image.isEmpty) { - return const Icon(Icons.error); - } - // cover 80% of parent height - return Image.memory( - image, - fit: BoxFit.cover, - cacheWidth: - (height * MediaQuery.of(context).devicePixelRatio) + child: InkWell( + onTap: () { + // open the book + context.pushNamed( + Routes.libraryItem.name, + pathParameters: { + Routes.libraryItem.pathParamName: item.id, + }, + extra: LibraryItemExtras( + book: book, + heroTagSuffix: heroTagSuffix, + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: coverImage.when( + data: (image) { + // return const BookCoverSkeleton(); + if (image.isEmpty) { + return const Icon(Icons.error); + } + // cover 80% of parent height + return Hero( + tag: HeroTagPrefixes.bookCover + + item.id + + heroTagSuffix, + child: Image.memory( + image, + fit: BoxFit.cover, + cacheWidth: (height * + MediaQuery.of(context).devicePixelRatio) .round(), - ); - }, - loading: () { - return const Center(child: BookCoverSkeleton()); - }, - error: (error, stack) { - return const Icon(Icons.error); - }, + ), + ); + }, + loading: () { + return const Center(child: BookCoverSkeleton()); + }, + error: (error, stack) { + return const Icon(Icons.error); + }, + ), ), ), ),