lib item page ready

This commit is contained in:
Dr-Blank 2024-05-12 05:38:30 -04:00
parent 0d54f1cb15
commit 097caf8ec2
No known key found for this signature in database
GPG key ID: 7452CC63F210A266
15 changed files with 804 additions and 221 deletions

View file

@ -8,5 +8,11 @@
"**/*.freezed.dart": true, "**/*.freezed.dart": true,
"**/*.g.dart": true "**/*.g.dart": true
}, },
"cSpell.words": ["Autovalidate", "mocktail", "riverpod", "shelfsdk"] "cSpell.words": [
"Autovalidate",
"mocktail",
"riverpod",
"shelfsdk",
"tapable"
]
} }

View file

@ -44,6 +44,8 @@ class CoverImage extends _$CoverImage {
'cover image stale for ${libraryItem.id}, fetching from the server', 'cover image stale for ${libraryItem.id}, fetching from the server',
); );
} }
} else {
debugPrint('cover image not found in cache for ${libraryItem.id}');
} }
// check if the image is in the cache // check if the image is in the cache
@ -57,6 +59,7 @@ class CoverImage extends _$CoverImage {
libraryItem.id, libraryItem.id,
coverImage, coverImage,
key: libraryItem.id, key: libraryItem.id,
fileExtension: 'jpg',
); );
debugPrint( debugPrint(
'cover image fetched for for ${libraryItem.id}, file time: ${await newFile.lastModified()}', 'cover image fetched for for ${libraryItem.id}, file time: ${await newFile.lastModified()}',

View file

@ -6,7 +6,7 @@ part of 'image_provider.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$coverImageHash() => r'010200735fbe7567ffdaad68bc5a98a475dfda42'; String _$coverImageHash() => r'3f4ef56a2539dd2082e7de55098bed8876098e9f';
/// Copied from Dart SDK /// Copied from Dart SDK
class _SystemHash { class _SystemHash {

View file

@ -1,6 +1,6 @@
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
part '../image.g.dart'; part 'image.g.dart';
/// Represents a cover image for a library item /// Represents a cover image for a library item
/// ///

View file

@ -1,6 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
part of 'schemas/image.dart'; part of 'image.dart';
// ************************************************************************** // **************************************************************************
// _IsarCollectionGenerator // _IsarCollectionGenerator
@ -16,7 +16,7 @@ extension GetImageCollection on Isar {
const ImageSchema = IsarGeneratedSchema( const ImageSchema = IsarGeneratedSchema(
schema: IsarSchema( schema: IsarSchema(
name: 'Image', name: 'CacheImage',
idName: 'id', idName: 'id',
embedded: false, embedded: false,
properties: [ properties: [

View file

@ -1,8 +1,9 @@
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:whispering_pages/settings/constants.dart';
final imageCacheManager = CacheManager( final imageCacheManager = CacheManager(
Config( Config(
'image_cache_manager', '${AppMetadata.appNameLowerCase}_image_cache',
stalePeriod: const Duration(days: 365 * 10), stalePeriod: const Duration(days: 365 * 10),
repo: JsonCacheInfoRepository(), repo: JsonCacheInfoRepository(),
maxNrOfCacheObjects: 1000, maxNrOfCacheObjects: 1000,
@ -11,8 +12,8 @@ final imageCacheManager = CacheManager(
final apiResponseCacheManager = CacheManager( final apiResponseCacheManager = CacheManager(
Config( Config(
'api_response_cache_manager', '${AppMetadata.appNameLowerCase}_api_response_cache',
stalePeriod: const Duration(days: 1), stalePeriod: const Duration(days: 7),
repo: JsonCacheInfoRepository(), repo: JsonCacheInfoRepository(),
maxNrOfCacheObjects: 1000, maxNrOfCacheObjects: 1000,
), ),

View file

@ -2,6 +2,7 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@ -15,12 +16,13 @@ Future initStorage() async {
// use whispering_pages as the directory for hive // use whispering_pages as the directory for hive
final storageDir = Directory(p.join( final storageDir = Directory(p.join(
dir.path, dir.path,
AppMetadata.appName.toLowerCase().replaceAll(' ', '_'), AppMetadata.appNameLowerCase,
), ),
); );
await storageDir.create(recursive: true); await storageDir.create(recursive: true);
Hive.defaultDirectory = storageDir.path; Hive.defaultDirectory = storageDir.path;
debugPrint('Hive storage directory init: ${Hive.defaultDirectory}');
await registerModels(); await registerModels();
} }

View file

@ -27,7 +27,14 @@ class AppSettingsPage extends HookConsumerWidget {
body: SettingsList( body: SettingsList(
sections: [ sections: [
SettingsSection( SettingsSection(
title: const Text('Appearance'), margin: const EdgeInsetsDirectional.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
title: Text(
'Appearance',
style: Theme.of(context).textTheme.titleLarge,
),
tiles: [ tiles: [
SettingsTile.switchTile( SettingsTile.switchTile(
initialValue: appSettings.isDarkMode, initialValue: appSettings.isDarkMode,
@ -42,11 +49,13 @@ class AppSettingsPage extends HookConsumerWidget {
), ),
SettingsTile.switchTile( SettingsTile.switchTile(
initialValue: appSettings.useMaterialThemeOnItemPage, initialValue: appSettings.useMaterialThemeOnItemPage,
title: const Text('Use Material Theming on Item Page'), title: const Text('Adaptive Theme on Item Page'),
description: const Text( description: const Text(
'get fancy with the colors on the item page at the cost of some performance', 'get fancy with the colors on the item page at the cost of some performance',
), ),
leading: const Icon(Icons.dynamic_form_outlined), leading: appSettings.useMaterialThemeOnItemPage
? const Icon(Icons.auto_fix_high)
: const Icon(Icons.auto_fix_off),
onToggle: (value) { onToggle: (value) {
ref.read(appSettingsProvider.notifier).updateState( ref.read(appSettingsProvider.notifier).updateState(
appSettings.copyWith( appSettings.copyWith(

View file

@ -14,6 +14,7 @@ import 'package:whispering_pages/settings/app_settings_provider.dart';
import 'package:whispering_pages/theme/theme_from_cover_provider.dart'; import 'package:whispering_pages/theme/theme_from_cover_provider.dart';
import 'package:whispering_pages/widgets/shelves/book_shelf.dart'; import 'package:whispering_pages/widgets/shelves/book_shelf.dart';
import '../widgets/expandable_description.dart';
import '../widgets/library_item_sliver_app_bar.dart'; import '../widgets/library_item_sliver_app_bar.dart';
class LibraryItemPage extends HookConsumerWidget { class LibraryItemPage extends HookConsumerWidget {
@ -34,9 +35,9 @@ class LibraryItemPage extends HookConsumerWidget {
? Image.memory(extraMap!.coverImage!) ? Image.memory(extraMap!.coverImage!)
: null; : null;
final item = ref.watch(libraryItemProvider(itemId)); final itemFromApi = ref.watch(libraryItemProvider(itemId));
var itemBookMetadata = var itemBookMetadata =
item.valueOrNull?.media.metadata as shelfsdk.BookMetadata?; itemFromApi.valueOrNull?.media.metadata as shelfsdk.BookMetadata?;
final useMaterialThemeOnItemPage = final useMaterialThemeOnItemPage =
ref.watch(appSettingsProvider).useMaterialThemeOnItemPage; ref.watch(appSettingsProvider).useMaterialThemeOnItemPage;
@ -44,7 +45,7 @@ class LibraryItemPage extends HookConsumerWidget {
if (useMaterialThemeOnItemPage) { if (useMaterialThemeOnItemPage) {
coverColorScheme = ref.watch( coverColorScheme = ref.watch(
themeOfLibraryItemProvider( themeOfLibraryItemProvider(
item.valueOrNull, itemFromApi.valueOrNull,
brightness: Theme.of(context).brightness, brightness: Theme.of(context).brightness,
), ),
); );
@ -56,13 +57,6 @@ class LibraryItemPage extends HookConsumerWidget {
return ThemeProvider( return ThemeProvider(
initTheme: Theme.of(context), initTheme: Theme.of(context),
duration: 200.ms, duration: 200.ms,
// data: coverColorScheme.valueOrNull != null && useMaterialThemeOnItemPage
// ? ThemeData.from(
// colorScheme: coverColorScheme.valueOrNull!,
// textTheme: Theme.of(context).textTheme,
// )
// : Theme.of(context),
child: ThemeSwitchingArea( child: ThemeSwitchingArea(
child: Builder( child: Builder(
builder: (context) { builder: (context) {
@ -76,13 +70,39 @@ class LibraryItemPage extends HookConsumerWidget {
itemId: itemId, itemId: itemId,
extraMap: extraMap, extraMap: extraMap,
providedCacheImage: providedCacheImage, providedCacheImage: providedCacheImage,
item: item, item: itemFromApi,
itemBookMetadata: itemBookMetadata, itemBookMetadata: itemBookMetadata,
bookDetailsCached: bookDetailsCached, bookDetailsCached: bookDetailsCached,
coverColorScheme: coverColorScheme, coverColorScheme: coverColorScheme,
useMaterialThemeOnItemPage: useMaterialThemeOnItemPage,
), ),
), ),
// a horizontal display with dividers of metadata
SliverToBoxAdapter(
child: itemFromApi.valueOrNull != null
? LibraryItemMetadata(
item: itemFromApi.valueOrNull!,
itemBookMetadata: itemBookMetadata,
bookDetailsCached: bookDetailsCached,
)
: const SizedBox.shrink(),
),
// a row of actions like play, download, share, etc
const SliverPadding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
sliver: LibraryItemActions(),
),
// a expandable section for book description
SliverToBoxAdapter(
child:
itemFromApi.valueOrNull?.media.metadata.description !=
null
? ExpandableDescription(
title: 'About the Book',
content: itemFromApi
.valueOrNull!.media.metadata.description!,
)
: const SizedBox.shrink(),
),
], ],
), ),
); );
@ -93,7 +113,228 @@ class LibraryItemPage extends HookConsumerWidget {
} }
} }
class LibraryItemHeroSection extends StatelessWidget { class LibraryItemMetadata extends StatelessWidget {
const LibraryItemMetadata({
super.key,
required this.item,
this.itemBookMetadata,
this.bookDetailsCached,
});
final shelfsdk.LibraryItem item;
final shelfsdk.BookMetadata? itemBookMetadata;
final shelfsdk.BookMinified? bookDetailsCached;
@override
Widget build(BuildContext context) {
final children = [
// duration of the book
_MetadataItem(
title: switch (itemBookMetadata?.abridged) {
true => 'Abridged',
false => 'Unabridged',
_ => 'Length',
},
value: getDurationFormatted() ?? 'time is just a concept',
),
_MetadataItem(
title: 'Published',
value: itemBookMetadata?.publishedDate ??
itemBookMetadata?.publishedYear ??
'Unknown',
),
_MetadataItem(
title: getCodecAndBitrate() ?? 'Codec & Bitrate',
value: getSizeFormatted() ?? 'Unknown',
),
];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: IntrinsicHeight(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
// alternate between metadata and vertical divider
children: List.generate(
children.length * 2 - 1,
(index) {
if (index.isEven) {
return children[index ~/ 2];
}
return VerticalDivider(
indent: 6,
endIndent: 6,
color:
Theme.of(context).colorScheme.onBackground.withOpacity(0.6),
);
},
),
),
),
);
}
/// formats the duration of the book as `10h 30m`
///
/// will add up all the durations of the audio files first
/// then convert them to the given format
String? getDurationFormatted() {
final book = (item.media as shelfsdk.Book?);
if (book == null) {
return null;
}
final duration = book.audioFiles
.map((e) => e.duration)
.reduce((value, element) => value + element);
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
return '${hours}h ${minutes}m';
}
/// will return the size of the book in MB
///
/// will add up all the sizes of the audio files first
/// then convert them to MB
String? getSizeFormatted() {
final book = (item.media as shelfsdk.Book?);
if (book == null) {
return null;
}
final size = book.audioFiles
.map((e) => e.metadata.size)
.reduce((value, element) => value + element);
return '${size / 1024 ~/ 1024} MB';
}
/// will return the codec and bitrate of the book
String? getCodecAndBitrate() {
final book = (item.media as shelfsdk.Book?);
if (book == null) {
return null;
}
final codec = book.audioFiles.first.codec.toUpperCase();
final bitrate = book.audioFiles.first.bitRate;
return codec;
}
}
/// key-value pair to display as column
class _MetadataItem extends StatelessWidget {
const _MetadataItem({
super.key,
required this.title,
required this.value,
});
final String title;
final String value;
@override
Widget build(BuildContext context) {
final themeData = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
style: themeData.textTheme.titleMedium?.copyWith(
color: themeData.colorScheme.onBackground.withOpacity(0.90),
),
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
style: themeData.textTheme.bodySmall?.copyWith(
color: themeData.colorScheme.onBackground.withOpacity(0.7),
),
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
);
}
}
class LibraryItemActions extends StatelessWidget {
const LibraryItemActions({
super.key,
});
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Container(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
// play/resume button the same widht as image
LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: calculateWidth(context, constraints),
// a boxy button with icon and text but little rounded corner
child: ElevatedButton.icon(
onPressed: () {},
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Play/Resume'),
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
),
);
},
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: constraints.maxWidth * 0.6,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// read list button
IconButton(
onPressed: () {},
icon: const Icon(
Icons.playlist_add_rounded,
),
),
// share button
IconButton(
onPressed: () {},
icon: const Icon(Icons.share_rounded),
),
// download button
IconButton(
onPressed: () {},
icon: const Icon(
Icons.download_rounded,
),
),
// more button
IconButton(
onPressed: () {},
icon: const Icon(
Icons.more_vert_rounded,
),
),
],
),
);
},
),
),
],
),
),
);
}
}
class LibraryItemHeroSection extends HookConsumerWidget {
const LibraryItemHeroSection({ const LibraryItemHeroSection({
super.key, super.key,
required this.itemId, required this.itemId,
@ -103,10 +344,8 @@ class LibraryItemHeroSection extends StatelessWidget {
required this.itemBookMetadata, required this.itemBookMetadata,
required this.bookDetailsCached, required this.bookDetailsCached,
required this.coverColorScheme, required this.coverColorScheme,
required this.useMaterialThemeOnItemPage,
}); });
final bool useMaterialThemeOnItemPage;
final String itemId; final String itemId;
final LibraryItemExtras? extraMap; final LibraryItemExtras? extraMap;
final Image? providedCacheImage; final Image? providedCacheImage;
@ -116,52 +355,127 @@ class LibraryItemHeroSection extends StatelessWidget {
final AsyncValue<ColorScheme?> coverColorScheme; final AsyncValue<ColorScheme?> coverColorScheme;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Row( child: LayoutBuilder(
crossAxisAlignment: CrossAxisAlignment.end, builder: (context, constraints) {
children: [ return Container(
LayoutBuilder( child: Row(
builder: (context, constraints) { crossAxisAlignment: CrossAxisAlignment.start,
return SizedBox( mainAxisAlignment: MainAxisAlignment.start,
height: calculateWidth( children: [
context, // book cover
constraints, LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: calculateWidth(context, constraints),
child: Hero(
tag: HeroTagPrefixes.bookCover +
itemId +
(extraMap?.heroTagSuffix ?? ''),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: _BookCover(
itemId: itemId,
extraMap: extraMap,
providedCacheImage: providedCacheImage,
coverColorScheme: coverColorScheme.valueOrNull,
item: item,
),
),
),
);
},
), ),
child: ClipRRect( // book details
borderRadius: BorderRadius.circular(8), Expanded(
child: _BookCover( child: Padding(
itemId: itemId, padding: const EdgeInsets.symmetric(horizontal: 8),
extraMap: extraMap, child: Column(
providedCacheImage: providedCacheImage, crossAxisAlignment: CrossAxisAlignment.start,
coverColorScheme: coverColorScheme.valueOrNull, mainAxisAlignment: MainAxisAlignment.start,
item: item, children: [
_BookTitle(
extraMap: extraMap,
itemBookMetadata: itemBookMetadata,
bookDetailsCached: bookDetailsCached,
),
Container(
margin: const EdgeInsets.symmetric(vertical: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
// authors info if available
_BookAuthors(
itemBookMetadata: itemBookMetadata,
bookDetailsCached: bookDetailsCached,
),
// narrators info if available
_BookNarrators(
itemBookMetadata: itemBookMetadata,
bookDetailsCached: bookDetailsCached,
),
// series info if available
_BookSeries(
itemBookMetadata: itemBookMetadata,
bookDetailsCached: bookDetailsCached,
),
],
),
),
],
),
), ),
), ),
);
},
),
const SizedBox.square(
dimension: 8,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_BookTitle(
extraMap: extraMap,
itemBookMetadata: itemBookMetadata,
bookDetailsCached: bookDetailsCached,
),
_BookAuthors(
itemBookMetadata: itemBookMetadata,
bookDetailsCached: bookDetailsCached,
),
// series info if available
// narrators info if available
], ],
), ),
);
},
),
);
}
}
class _HeroSectionSubLabelWithIcon extends HookConsumerWidget {
const _HeroSectionSubLabelWithIcon({
super.key,
required this.icon,
required this.text,
});
final IconData icon;
final Widget text;
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeData = Theme.of(context);
final useFontAwesome =
icon.runtimeType == FontAwesomeIcons.book.runtimeType;
final useMaterialThemeOnItemPage =
ref.watch(appSettingsProvider).useMaterialThemeOnItemPage;
final color = useMaterialThemeOnItemPage
? themeData.colorScheme.primary
: themeData.colorScheme.onBackground.withOpacity(0.75);
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Container(
margin: const EdgeInsets.only(right: 8, top: 2),
child: useFontAwesome
? FaIcon(
icon,
size: 16,
color: color,
)
: Icon(
icon,
size: 16,
color: color,
),
),
Expanded(
child: text,
), ),
], ],
), ),
@ -169,6 +483,90 @@ class LibraryItemHeroSection extends StatelessWidget {
} }
} }
class _BookSeries extends StatelessWidget {
const _BookSeries({
super.key,
required this.itemBookMetadata,
required this.bookDetailsCached,
});
final shelfsdk.BookMetadata? itemBookMetadata;
final shelfsdk.BookMinified? bookDetailsCached;
@override
Widget build(BuildContext context) {
final themeData = Theme.of(context);
String generateSeriesString() {
final series = (itemBookMetadata)?.series ?? <shelfsdk.SeriesSequence>[];
if (series.isEmpty) {
return (bookDetailsCached?.metadata as shelfsdk.BookMetadataMinified?)
?.seriesName ??
'';
}
return series
.map((e) {
try {
e as shelfsdk.SeriesSequence;
final seq = e.sequence != null ? '#${e.sequence} of ' : '';
return '$seq${e.name}';
} catch (e) {
return '';
}
})
.toList()
.join(', ');
}
return generateSeriesString() == ''
? const SizedBox.shrink()
: _HeroSectionSubLabelWithIcon(
icon: Icons.library_books_rounded,
text: Text(
style: themeData.textTheme.titleSmall,
generateSeriesString(),
),
);
}
}
class _BookNarrators extends StatelessWidget {
const _BookNarrators({
super.key,
required this.itemBookMetadata,
required this.bookDetailsCached,
});
final shelfsdk.BookMetadata? itemBookMetadata;
final shelfsdk.BookMinified? bookDetailsCached;
@override
Widget build(BuildContext context) {
String generateNarratorsString() {
final narrators = (itemBookMetadata)?.narrators ?? [];
if (narrators.isEmpty) {
return (bookDetailsCached?.metadata as shelfsdk.BookMetadataMinified?)
?.narratorName ??
'';
}
return narrators.join(', ');
}
final themeData = Theme.of(context);
return generateNarratorsString() == ''
? const SizedBox.shrink()
: _HeroSectionSubLabelWithIcon(
icon: Icons.record_voice_over,
text: Text(
style: themeData.textTheme.titleSmall,
generateNarratorsString(),
),
);
}
}
class _BookCover extends HookConsumerWidget { class _BookCover extends HookConsumerWidget {
const _BookCover({ const _BookCover({
super.key, super.key,
@ -187,60 +585,61 @@ class _BookCover extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final themeData = Theme.of(context);
final useMaterialThemeOnItemPage =
ref.watch(appSettingsProvider).useMaterialThemeOnItemPage;
return ThemeSwitcher( return ThemeSwitcher(
builder: (context) { builder: (context) {
// change theme after 2 seconds // change theme after 2 seconds
Future.delayed(150.ms, () { if (useMaterialThemeOnItemPage) {
ThemeSwitcher.of(context).changeTheme( Future.delayed(150.ms, () {
theme: coverColorScheme != null ThemeSwitcher.of(context).changeTheme(
? ThemeData.from( theme: coverColorScheme != null
colorScheme: coverColorScheme!, ? ThemeData.from(
textTheme: Theme.of(context).textTheme, colorScheme: coverColorScheme!,
) textTheme: themeData.textTheme,
: Theme.of(context), )
); : themeData,
}); );
return Hero( });
tag: HeroTagPrefixes.bookCover + }
itemId + return providedCacheImage ??
(extraMap?.heroTagSuffix ?? ''), item.when(
child: providedCacheImage ?? data: (libraryItem) {
item.when( final coverImage = ref.watch(coverImageProvider(libraryItem));
data: (libraryItem) { return Stack(
final coverImage = ref.watch(coverImageProvider(libraryItem)); children: [
return Stack( coverImage.when(
children: [ data: (image) {
coverImage.when( // return const BookCoverSkeleton();
data: (image) { if (image.isEmpty) {
// 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)
// .round(),
);
},
loading: () {
return const Center(
child: BookCoverSkeleton(),
);
},
error: (error, stack) {
return const Icon(Icons.error); return const Icon(Icons.error);
}, }
), // cover 80% of parent height
], return Image.memory(
); image,
}, fit: BoxFit.cover,
error: (error, stack) => const Icon(Icons.error), // cacheWidth: (height *
loading: () => const Center(child: BookCoverSkeleton()), // MediaQuery.of(context).devicePixelRatio)
), // .round(),
); );
},
loading: () {
return const Center(
child: BookCoverSkeleton(),
);
},
error: (error, stack) {
return const Icon(Icons.error);
},
),
],
);
},
error: (error, stack) => const Icon(Icons.error),
loading: () => const Center(child: BookCoverSkeleton()),
);
}, },
); );
} }
@ -260,25 +659,43 @@ class _BookTitle extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Hero( final themeData = Theme.of(context);
tag: HeroTagPrefixes.bookTitle + return Column(
// itemId + crossAxisAlignment: CrossAxisAlignment.start,
(extraMap?.heroTagSuffix ?? ''), children: [
child: Text( Hero(
// mode: AutoScrollTextMode.bouncing, tag: HeroTagPrefixes.bookTitle +
// curve: Curves.fastEaseInToSlowEaseOut, // itemId +
// velocity: const Velocity(pixelsPerSecond: Offset(30, 0)), (extraMap?.heroTagSuffix ?? ''),
// delayBefore: 500.ms, child: Text(
// pauseBetween: 150.ms, // mode: AutoScrollTextMode.bouncing,
// numberOfReps: 3, // curve: Curves.fastEaseInToSlowEaseOut,
style: Theme.of(context).textTheme.headlineSmall, // velocity: const Velocity(pixelsPerSecond: Offset(30, 0)),
itemBookMetadata?.title ?? bookDetailsCached?.metadata.title ?? '', // delayBefore: 500.ms,
), // pauseBetween: 150.ms,
// numberOfReps: 3,
style: themeData.textTheme.headlineLarge,
itemBookMetadata?.title ?? bookDetailsCached?.metadata.title ?? '',
),
),
// subtitle if available
itemBookMetadata?.subtitle == null
? const SizedBox.shrink()
: Text(
style: themeData.textTheme.titleSmall?.copyWith(
color: themeData
.colorScheme
.onBackground
.withOpacity(0.8),
),
itemBookMetadata?.subtitle ?? '',
),
],
); );
} }
} }
class _BookAuthors extends HookConsumerWidget { class _BookAuthors extends StatelessWidget {
const _BookAuthors({ const _BookAuthors({
super.key, super.key,
required this.itemBookMetadata, required this.itemBookMetadata,
@ -289,7 +706,8 @@ class _BookAuthors extends HookConsumerWidget {
final shelfsdk.BookMinified? bookDetailsCached; final shelfsdk.BookMinified? bookDetailsCached;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
final themeData = Theme.of(context);
String generateAuthorsString() { String generateAuthorsString() {
final authors = (itemBookMetadata)?.authors ?? []; final authors = (itemBookMetadata)?.authors ?? [];
if (authors.isEmpty) { if (authors.isEmpty) {
@ -300,29 +718,15 @@ class _BookAuthors extends HookConsumerWidget {
return authors.map((e) => e.name).join(', '); return authors.map((e) => e.name).join(', ');
} }
final useMaterialThemeOnItemPage = return generateAuthorsString() == ''
ref.watch(appSettingsProvider).useMaterialThemeOnItemPage; ? const SizedBox.shrink()
: _HeroSectionSubLabelWithIcon(
return Row( icon: FontAwesomeIcons.penNib,
children: [ text: Text(
Container( style: themeData.textTheme.titleSmall,
margin: const EdgeInsets.only(right: 8), generateAuthorsString(),
child: FaIcon( ),
FontAwesomeIcons.penNib, );
size: 16,
color: useMaterialThemeOnItemPage
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onBackground.withOpacity(0.75),
),
),
Expanded(
child: Text(
style: Theme.of(context).textTheme.titleSmall,
generateAuthorsString(),
),
),
],
);
} }
} }
@ -334,7 +738,7 @@ double calculateWidth(
double widthRatio = 0.4, double widthRatio = 0.4,
/// height ratio of the cover image to the available height /// height ratio of the cover image to the available height
double maxHeightToUse = 0.2, double maxHeightToUse = 0.25,
}) { }) {
final availHeight = final availHeight =
min(constraints.maxHeight, MediaQuery.of(context).size.height); min(constraints.maxHeight, MediaQuery.of(context).size.height);

View file

@ -13,8 +13,8 @@ part 'constants.dart';
final GlobalKey<NavigatorState> _rootNavigatorKey = final GlobalKey<NavigatorState> _rootNavigatorKey =
GlobalKey<NavigatorState>(debugLabel: 'root'); GlobalKey<NavigatorState>(debugLabel: 'root');
final GlobalKey<NavigatorState> _sectionANavigatorKey = final GlobalKey<NavigatorState> _sectionHomeNavigatorKey =
GlobalKey<NavigatorState>(debugLabel: 'sectionANav'); GlobalKey<NavigatorState>(debugLabel: 'HomeNavigator');
// GoRouter configuration // GoRouter configuration
class MyAppRouter { class MyAppRouter {
@ -43,7 +43,7 @@ class MyAppRouter {
branches: <StatefulShellBranch>[ branches: <StatefulShellBranch>[
// The route branch for the first tab of the bottom navigation bar. // The route branch for the first tab of the bottom navigation bar.
StatefulShellBranch( StatefulShellBranch(
navigatorKey: _sectionANavigatorKey, navigatorKey: _sectionHomeNavigatorKey,
routes: <RouteBase>[ routes: <RouteBase>[
GoRoute( GoRoute(
path: '/', path: '/',

View file

@ -5,4 +5,6 @@ import 'package:flutter/foundation.dart' show immutable;
class AppMetadata { class AppMetadata {
const AppMetadata._(); const AppMetadata._();
static const String appName = 'Whispering Pages'; static const String appName = 'Whispering Pages';
static get appNameLowerCase => appName.toLowerCase().replaceAll(' ', '_');
} }

View file

@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class ExpandableDescription extends HookWidget {
const ExpandableDescription({
super.key,
required this.title,
required this.content,
this.readMoreText = 'Read More',
this.readLessText = 'Read Less',
});
/// the title of the description section
final String title;
/// the collapsible content
final String content;
/// the text to show when the description is collapsed
final String readMoreText;
/// the text to show when the description is expanded
final String readLessText;
@override
Widget build(BuildContext context) {
var isDescExpanded = useState(false);
const duration = Duration(milliseconds: 300);
final descriptionAnimationController = useAnimationController(
duration: duration,
);
final themeData = Theme.of(context);
final textTheme = themeData.textTheme;
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// header with carrot icon is tapable
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
isDescExpanded.value = !isDescExpanded.value;
if (isDescExpanded.value) {
descriptionAnimationController.forward();
} else {
descriptionAnimationController.reverse();
}
},
// a header with a carrot icon
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// header text
Text(
style: textTheme.titleMedium,
title,
),
// carrot icon
AnimatedRotation(
turns: isDescExpanded.value ? 0.5 : 0,
duration: duration,
curve: Curves.easeInOutCubic,
child: const Icon(Icons.expand_more_rounded),
),
],
),
),
),
// description with maxLines of 3
// for now leave animation, just toggle the maxLines
// TODO: add animation using custom ticker that will animate the maxLines
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: AnimatedSwitcher(
duration: duration * 3,
child: isDescExpanded.value
? Text(
style: textTheme.bodyMedium,
content,
maxLines: null,
)
: Text(
style: textTheme.bodyMedium,
content,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
),
// a Read More / Read Less button at the end of the description
// if the description is expanded, then the button will say Read Less
// if the description is collapsed, then the button will say Read More
// the button will be at the end of the description
// the button will be tapable
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
isDescExpanded.value = !isDescExpanded.value;
if (isDescExpanded.value) {
descriptionAnimationController.forward();
} else {
descriptionAnimationController.reverse();
}
},
child: Text(
style: textTheme.bodySmall?.copyWith(
color: themeData.colorScheme.primary,
),
isDescExpanded.value ? readLessText : readMoreText,
),
),
),
],
),
);
}
}

View file

@ -8,7 +8,7 @@ class LibraryItemSliverAppBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SliverAppBar( return SliverAppBar(
// backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
floating: true, floating: true,
primary: true, primary: true,

View file

@ -70,61 +70,60 @@ class BookOnShelf extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// the cover image of the book // the cover image of the book
// take up remaining space // take up remaining space hence the expanded
Expanded( Expanded(
child: Center( child: Center(
child: ClipRRect( child: Hero(
borderRadius: BorderRadius.circular(10), tag: HeroTagPrefixes.bookCover + item.id + heroTagSuffix,
child: coverImage.when(
data: (image) { child: ClipRRect(
// return const BookCoverSkeleton(); borderRadius: BorderRadius.circular(10),
if (image.isEmpty) { child: coverImage.when(
return const Icon(Icons.error); data: (image) {
} // return const BookCoverSkeleton();
var imageWidget = InkWell( if (image.isEmpty) {
onTap: () { return const Icon(Icons.error);
// open the book }
context.pushNamed( var imageWidget = InkWell(
Routes.libraryItem.name, onTap: () {
pathParameters: { // open the book
Routes.libraryItem.pathParamName!: item.id, context.pushNamed(
}, Routes.libraryItem.name,
extra: LibraryItemExtras( pathParameters: {
book: book, Routes.libraryItem.pathParamName!: item.id,
heroTagSuffix: heroTagSuffix, },
coverImage: coverImage.valueOrNull, extra: LibraryItemExtras(
), book: book,
); heroTagSuffix: heroTagSuffix,
}, coverImage: coverImage.valueOrNull,
child: Image.memory( ),
image, );
fit: BoxFit.fill, },
cacheWidth: (height * child: Image.memory(
1.2 * image,
MediaQuery.of(context).devicePixelRatio) fit: BoxFit.fill,
.round(), cacheWidth: (height *
), 1.2 *
); MediaQuery.of(context).devicePixelRatio)
return Hero( .round(),
tag: HeroTagPrefixes.bookCover + ),
item.id + );
heroTagSuffix, return Container(
child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.onPrimaryContainer, .onPrimaryContainer,
), ),
child: imageWidget, child: imageWidget,
), );
); },
}, loading: () {
loading: () { return const Center(child: BookCoverSkeleton());
return const Center(child: BookCoverSkeleton()); },
}, error: (error, stack) {
error: (error, stack) { return const Icon(Icons.error);
return const Icon(Icons.error); },
}, ),
), ),
), ),
), ),

View file

@ -56,29 +56,62 @@ class SimpleHomeShelf extends HookConsumerWidget {
// if height is null take up 30% of the smallest screen dimension // if height is null take up 30% of the smallest screen dimension
return Padding( return Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(title, style: Theme.of(context).textTheme.titleLarge), Padding(
const SizedBox(height: 16), padding: const EdgeInsets.only(left: 8.0, bottom: 8.0),
child: Text(title, style: Theme.of(context).textTheme.titleLarge),
),
// fix the height of the shelf as a percentage of the screen height
SizedBox( SizedBox(
height: max( height: height ?? getDefaultShelfHeight(context, perCent: 0.5),
min(
height ?? 0.3 * MediaQuery.of(context).size.shortestSide,
200.0,
),
150.0,
),
child: ListView.separated( child: ListView.separated(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemBuilder: (context, index) => children[index], itemBuilder: (context, index) {
separatorBuilder: (context, index) => const SizedBox(width: 16), if (index == 0 || index == children.length + 1) {
itemCount: children.length, return const SizedBox(
width: 8,
);
}
return children[index - 1];
},
separatorBuilder: (context, index) {
if (index == 0 || index == children.length + 1) {
return const SizedBox();
}
return const SizedBox(width: 16);
},
itemCount: children.length +
2, // add some extra space at the start and end so that the first and last items are not at the edge
), ),
), ),
], ],
), ),
); );
} }
/// get the height of the shelf based on the screen size
/// the height is the height parent wants the shelf to be
/// but it should not be less than 150 so we take the max of 150 and the height in the end
/// ignoreWidth is used to ignore the width of the screen and take only the height into consideration else smallest side is taken so that shelf is not too big on tablets
double getDefaultShelfHeight(
BuildContext context, {
bool ignoreWidth = false,
atMin = 150.0,
perCent = 0.3,
}) {
double referenceSide;
if (ignoreWidth) {
referenceSide = MediaQuery.of(context).size.height;
} else {
referenceSide = min(
MediaQuery.of(context).size.width,
MediaQuery.of(context).size.height,
);
}
return max(atMin, referenceSide * perCent);
}
} }