This commit is contained in:
Dr-Blank 2024-05-09 00:41:19 -04:00
parent ebc14a0448
commit f8597f7430
No known key found for this signature in database
GPG key ID: 7452CC63F210A266
13 changed files with 509 additions and 33 deletions

View file

@ -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_';
}

View file

@ -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,
);
}
}

View file

@ -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,
),
),
),
);
}
}

View file

@ -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(),
),
);
}
}

View file

@ -1,2 +0,0 @@
export 'home_page.dart';
export 'server_manager.dart';

34
lib/router/constants.dart Normal file
View file

@ -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';
}

View file

@ -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<String, dynamic> json) =>
_$LibraryItemExtrasFromJson(json);
}

View file

@ -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>(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<String, dynamic> json) {
return _LibraryItemExtras.fromJson(json);
}
/// @nodoc
mixin _$LibraryItemExtras {
BookMinified? get book => throw _privateConstructorUsedError;
String? get heroTagSuffix => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$LibraryItemExtrasCopyWith<LibraryItemExtras> 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<String, dynamic> 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<String, dynamic> toJson() {
return _$$LibraryItemExtrasImplToJson(
this,
);
}
}
abstract class _LibraryItemExtras implements LibraryItemExtras {
const factory _LibraryItemExtras(
{final BookMinified? book,
final String? heroTagSuffix}) = _$LibraryItemExtrasImpl;
factory _LibraryItemExtras.fromJson(Map<String, dynamic> json) =
_$LibraryItemExtrasImpl.fromJson;
@override
BookMinified? get book;
@override
String? get heroTagSuffix;
@override
@JsonKey(ignore: true)
_$$LibraryItemExtrasImplCopyWith<_$LibraryItemExtrasImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View file

@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'library_item_extras.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$LibraryItemExtrasImpl _$$LibraryItemExtrasImplFromJson(
Map<String, dynamic> json) =>
_$LibraryItemExtrasImpl(
book: json['book'] == null
? null
: BookMinified.fromJson(json['book'] as Map<String, dynamic>),
heroTagSuffix: json['heroTagSuffix'] as String?,
);
Map<String, dynamic> _$$LibraryItemExtrasImplToJson(
_$LibraryItemExtrasImpl instance) =>
<String, dynamic>{
'book': instance.book,
'heroTagSuffix': instance.heroTagSuffix,
};

52
lib/router/router.dart Normal file
View file

@ -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;
},
);
}

View file

@ -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;
}
}

View file

@ -6,7 +6,7 @@ part of 'api_settings_provider.dart';
// RiverpodGenerator
// **************************************************************************
String _$apiSettingsHash() => r'a6927751bd91ec7c9e1a2810dc939407d9112210';
String _$apiSettingsHash() => r'f08d87b716b31bfb4040fc6440840ac97b7ee686';
/// See also [ApiSettings].
@ProviderFor(ApiSettings)

View file

@ -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,7 +72,20 @@ class BookOnShelf extends HookConsumerWidget {
// the cover image of the book
// take up remaining space
Expanded(
// border radius
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(
@ -73,12 +95,17 @@ class BookOnShelf extends HookConsumerWidget {
return const Icon(Icons.error);
}
// cover 80% of parent height
return Image.memory(
return Hero(
tag: HeroTagPrefixes.bookCover +
item.id +
heroTagSuffix,
child: Image.memory(
image,
fit: BoxFit.cover,
cacheWidth:
(height * MediaQuery.of(context).devicePixelRatio)
cacheWidth: (height *
MediaQuery.of(context).devicePixelRatio)
.round(),
),
);
},
loading: () {
@ -90,6 +117,7 @@ class BookOnShelf extends HookConsumerWidget {
),
),
),
),
// the title and author of the book
// AutoScrollText(
Text(