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,
"**/*.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',
);
}
} else {
debugPrint('cover image not found in cache for ${libraryItem.id}');
}
// check if the image is in the cache
@ -57,6 +59,7 @@ class CoverImage extends _$CoverImage {
libraryItem.id,
coverImage,
key: libraryItem.id,
fileExtension: 'jpg',
);
debugPrint(
'cover image fetched for for ${libraryItem.id}, file time: ${await newFile.lastModified()}',

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -27,7 +27,14 @@ class AppSettingsPage extends HookConsumerWidget {
body: SettingsList(
sections: [
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: [
SettingsTile.switchTile(
initialValue: appSettings.isDarkMode,
@ -42,11 +49,13 @@ class AppSettingsPage extends HookConsumerWidget {
),
SettingsTile.switchTile(
initialValue: appSettings.useMaterialThemeOnItemPage,
title: const Text('Use Material Theming on Item Page'),
title: const Text('Adaptive Theme on Item Page'),
description: const Text(
'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) {
ref.read(appSettingsProvider.notifier).updateState(
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/widgets/shelves/book_shelf.dart';
import '../widgets/expandable_description.dart';
import '../widgets/library_item_sliver_app_bar.dart';
class LibraryItemPage extends HookConsumerWidget {
@ -34,9 +35,9 @@ class LibraryItemPage extends HookConsumerWidget {
? Image.memory(extraMap!.coverImage!)
: null;
final item = ref.watch(libraryItemProvider(itemId));
final itemFromApi = ref.watch(libraryItemProvider(itemId));
var itemBookMetadata =
item.valueOrNull?.media.metadata as shelfsdk.BookMetadata?;
itemFromApi.valueOrNull?.media.metadata as shelfsdk.BookMetadata?;
final useMaterialThemeOnItemPage =
ref.watch(appSettingsProvider).useMaterialThemeOnItemPage;
@ -44,7 +45,7 @@ class LibraryItemPage extends HookConsumerWidget {
if (useMaterialThemeOnItemPage) {
coverColorScheme = ref.watch(
themeOfLibraryItemProvider(
item.valueOrNull,
itemFromApi.valueOrNull,
brightness: Theme.of(context).brightness,
),
);
@ -56,13 +57,6 @@ class LibraryItemPage extends HookConsumerWidget {
return ThemeProvider(
initTheme: Theme.of(context),
duration: 200.ms,
// data: coverColorScheme.valueOrNull != null && useMaterialThemeOnItemPage
// ? ThemeData.from(
// colorScheme: coverColorScheme.valueOrNull!,
// textTheme: Theme.of(context).textTheme,
// )
// : Theme.of(context),
child: ThemeSwitchingArea(
child: Builder(
builder: (context) {
@ -76,13 +70,39 @@ class LibraryItemPage extends HookConsumerWidget {
itemId: itemId,
extraMap: extraMap,
providedCacheImage: providedCacheImage,
item: item,
item: itemFromApi,
itemBookMetadata: itemBookMetadata,
bookDetailsCached: bookDetailsCached,
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({
super.key,
required this.itemId,
@ -103,10 +344,8 @@ class LibraryItemHeroSection extends StatelessWidget {
required this.itemBookMetadata,
required this.bookDetailsCached,
required this.coverColorScheme,
required this.useMaterialThemeOnItemPage,
});
final bool useMaterialThemeOnItemPage;
final String itemId;
final LibraryItemExtras? extraMap;
final Image? providedCacheImage;
@ -116,20 +355,26 @@ class LibraryItemHeroSection extends StatelessWidget {
final AsyncValue<ColorScheme?> coverColorScheme;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
return SliverToBoxAdapter(
child: LayoutBuilder(
builder: (context, constraints) {
return Container(
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
// book cover
LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
height: calculateWidth(
context,
constraints,
),
width: calculateWidth(context, constraints),
child: Hero(
tag: HeroTagPrefixes.bookCover +
itemId +
(extraMap?.heroTagSuffix ?? ''),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(16),
child: _BookCover(
itemId: itemId,
extraMap: extraMap,
@ -138,33 +383,186 @@ class LibraryItemHeroSection extends StatelessWidget {
item: item,
),
),
),
);
},
),
const SizedBox.square(
dimension: 8,
),
// book details
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
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,
),
// series info if available
// narrators info if available
_BookNarrators(
itemBookMetadata: itemBookMetadata,
bookDetailsCached: bookDetailsCached,
),
// series info if available
_BookSeries(
itemBookMetadata: itemBookMetadata,
bookDetailsCached: bookDetailsCached,
),
],
),
),
],
),
),
),
],
),
);
},
),
);
}
}
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,
),
],
),
);
}
}
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(),
),
);
}
}
@ -187,24 +585,26 @@ class _BookCover extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeData = Theme.of(context);
final useMaterialThemeOnItemPage =
ref.watch(appSettingsProvider).useMaterialThemeOnItemPage;
return ThemeSwitcher(
builder: (context) {
// change theme after 2 seconds
if (useMaterialThemeOnItemPage) {
Future.delayed(150.ms, () {
ThemeSwitcher.of(context).changeTheme(
theme: coverColorScheme != null
? ThemeData.from(
colorScheme: coverColorScheme!,
textTheme: Theme.of(context).textTheme,
textTheme: themeData.textTheme,
)
: Theme.of(context),
: themeData,
);
});
return Hero(
tag: HeroTagPrefixes.bookCover +
itemId +
(extraMap?.heroTagSuffix ?? ''),
child: providedCacheImage ??
}
return providedCacheImage ??
item.when(
data: (libraryItem) {
final coverImage = ref.watch(coverImageProvider(libraryItem));
@ -239,7 +639,6 @@ class _BookCover extends HookConsumerWidget {
},
error: (error, stack) => const Icon(Icons.error),
loading: () => const Center(child: BookCoverSkeleton()),
),
);
},
);
@ -260,7 +659,11 @@ class _BookTitle extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Hero(
final themeData = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Hero(
tag: HeroTagPrefixes.bookTitle +
// itemId +
(extraMap?.heroTagSuffix ?? ''),
@ -271,14 +674,28 @@ class _BookTitle extends StatelessWidget {
// delayBefore: 500.ms,
// pauseBetween: 150.ms,
// numberOfReps: 3,
style: Theme.of(context).textTheme.headlineSmall,
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({
super.key,
required this.itemBookMetadata,
@ -289,7 +706,8 @@ class _BookAuthors extends HookConsumerWidget {
final shelfsdk.BookMinified? bookDetailsCached;
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final themeData = Theme.of(context);
String generateAuthorsString() {
final authors = (itemBookMetadata)?.authors ?? [];
if (authors.isEmpty) {
@ -300,28 +718,14 @@ class _BookAuthors extends HookConsumerWidget {
return authors.map((e) => e.name).join(', ');
}
final useMaterialThemeOnItemPage =
ref.watch(appSettingsProvider).useMaterialThemeOnItemPage;
return Row(
children: [
Container(
margin: const EdgeInsets.only(right: 8),
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,
return generateAuthorsString() == ''
? const SizedBox.shrink()
: _HeroSectionSubLabelWithIcon(
icon: FontAwesomeIcons.penNib,
text: Text(
style: themeData.textTheme.titleSmall,
generateAuthorsString(),
),
),
],
);
}
}
@ -334,7 +738,7 @@ double calculateWidth(
double widthRatio = 0.4,
/// height ratio of the cover image to the available height
double maxHeightToUse = 0.2,
double maxHeightToUse = 0.25,
}) {
final availHeight =
min(constraints.maxHeight, MediaQuery.of(context).size.height);

View file

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

View file

@ -5,4 +5,6 @@ import 'package:flutter/foundation.dart' show immutable;
class AppMetadata {
const AppMetadata._();
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
Widget build(BuildContext context) {
return SliverAppBar(
// backgroundColor: Colors.transparent,
backgroundColor: Colors.transparent,
elevation: 0,
floating: true,
primary: true,

View file

@ -70,9 +70,12 @@ class BookOnShelf extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// the cover image of the book
// take up remaining space
// take up remaining space hence the expanded
Expanded(
child: Center(
child: Hero(
tag: HeroTagPrefixes.bookCover + item.id + heroTagSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: coverImage.when(
@ -105,18 +108,13 @@ class BookOnShelf extends HookConsumerWidget {
.round(),
),
);
return Hero(
tag: HeroTagPrefixes.bookCover +
item.id +
heroTagSuffix,
child: Container(
return Container(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
child: imageWidget,
),
);
},
loading: () {
@ -129,6 +127,7 @@ class BookOnShelf extends HookConsumerWidget {
),
),
),
),
// the title and author of the book
// AutoScrollText(
Hero(

View file

@ -56,29 +56,62 @@ class SimpleHomeShelf extends HookConsumerWidget {
// if height is null take up 30% of the smallest screen dimension
return Padding(
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
Padding(
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(
height: max(
min(
height ?? 0.3 * MediaQuery.of(context).size.shortestSide,
200.0,
),
150.0,
),
height: height ?? getDefaultShelfHeight(context, perCent: 0.5),
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) => children[index],
separatorBuilder: (context, index) => const SizedBox(width: 16),
itemCount: children.length,
itemBuilder: (context, index) {
if (index == 0 || index == children.length + 1) {
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);
}
}