mirror of
https://github.com/Dr-Blank/Vaani.git
synced 2025-09-01 07:20:02 +02:00
lib item page ready
This commit is contained in:
parent
0d54f1cb15
commit
097caf8ec2
15 changed files with 804 additions and 221 deletions
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
|
@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
|
@ -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()}',
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
2
lib/db/cache/schemas/image.dart
vendored
2
lib/db/cache/schemas/image.dart
vendored
|
@ -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
|
||||||
///
|
///
|
||||||
|
|
|
@ -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: [
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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: '/',
|
||||||
|
|
|
@ -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(' ', '_');
|
||||||
}
|
}
|
||||||
|
|
124
lib/widgets/expandable_description.dart
Normal file
124
lib/widgets/expandable_description.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue