mirror of
https://github.com/Dr-Blank/Vaani.git
synced 2025-12-09 20:49:29 +00:00
lib item page ready
This commit is contained in:
parent
0d54f1cb15
commit
097caf8ec2
15 changed files with 804 additions and 221 deletions
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
|
||||
Widget build(BuildContext context) {
|
||||
return SliverAppBar(
|
||||
// backgroundColor: Colors.transparent,
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
floating: true,
|
||||
primary: true,
|
||||
|
|
|
|||
|
|
@ -70,61 +70,60 @@ 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: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: coverImage.when(
|
||||
data: (image) {
|
||||
// return const BookCoverSkeleton();
|
||||
if (image.isEmpty) {
|
||||
return const Icon(Icons.error);
|
||||
}
|
||||
var imageWidget = InkWell(
|
||||
onTap: () {
|
||||
// open the book
|
||||
context.pushNamed(
|
||||
Routes.libraryItem.name,
|
||||
pathParameters: {
|
||||
Routes.libraryItem.pathParamName!: item.id,
|
||||
},
|
||||
extra: LibraryItemExtras(
|
||||
book: book,
|
||||
heroTagSuffix: heroTagSuffix,
|
||||
coverImage: coverImage.valueOrNull,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Image.memory(
|
||||
image,
|
||||
fit: BoxFit.fill,
|
||||
cacheWidth: (height *
|
||||
1.2 *
|
||||
MediaQuery.of(context).devicePixelRatio)
|
||||
.round(),
|
||||
),
|
||||
);
|
||||
return Hero(
|
||||
tag: HeroTagPrefixes.bookCover +
|
||||
item.id +
|
||||
heroTagSuffix,
|
||||
child: Container(
|
||||
child: Hero(
|
||||
tag: HeroTagPrefixes.bookCover + item.id + heroTagSuffix,
|
||||
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: coverImage.when(
|
||||
data: (image) {
|
||||
// return const BookCoverSkeleton();
|
||||
if (image.isEmpty) {
|
||||
return const Icon(Icons.error);
|
||||
}
|
||||
var imageWidget = InkWell(
|
||||
onTap: () {
|
||||
// open the book
|
||||
context.pushNamed(
|
||||
Routes.libraryItem.name,
|
||||
pathParameters: {
|
||||
Routes.libraryItem.pathParamName!: item.id,
|
||||
},
|
||||
extra: LibraryItemExtras(
|
||||
book: book,
|
||||
heroTagSuffix: heroTagSuffix,
|
||||
coverImage: coverImage.valueOrNull,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Image.memory(
|
||||
image,
|
||||
fit: BoxFit.fill,
|
||||
cacheWidth: (height *
|
||||
1.2 *
|
||||
MediaQuery.of(context).devicePixelRatio)
|
||||
.round(),
|
||||
),
|
||||
);
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer,
|
||||
),
|
||||
child: imageWidget,
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () {
|
||||
return const Center(child: BookCoverSkeleton());
|
||||
},
|
||||
error: (error, stack) {
|
||||
return const Icon(Icons.error);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () {
|
||||
return const Center(child: BookCoverSkeleton());
|
||||
},
|
||||
error: (error, stack) {
|
||||
return const Icon(Icons.error);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue