Files
venera/lib/pages/comic_page.dart
2024-10-05 08:56:06 +08:00

754 lines
21 KiB
Dart

import 'package:flutter/material.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/utils/translations.dart';
import 'dart:math' as math;
class ComicPage extends StatefulWidget {
const ComicPage({super.key, required this.id, required this.sourceKey});
final String id;
final String sourceKey;
@override
State<ComicPage> createState() => _ComicPageState();
}
class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
with _ComicPageActions {
bool showAppbarTitle = false;
var scrollController = ScrollController();
@override
void initState() {
scrollController.addListener(onScroll);
super.initState();
}
@override
void update() {
setState(() {});
}
@override
ComicDetails get comic => data!;
void onScroll() {
if (scrollController.offset > 100) {
if (!showAppbarTitle) {
setState(() {
showAppbarTitle = true;
});
}
} else {
if (showAppbarTitle) {
setState(() {
showAppbarTitle = false;
});
}
}
}
@override
Widget buildContent(BuildContext context, ComicDetails data) {
return SmoothCustomScrollView(
controller: scrollController,
slivers: [
...buildTitle(),
buildActions(),
buildDescription(),
buildInfo(),
buildChapters(),
buildThumbnails(),
buildRecommend(),
],
);
}
@override
Future<Res<ComicDetails>> loadData() async {
var comicSource = ComicSource.find(widget.sourceKey);
isAddToLocalFav = LocalFavoritesManager().isExist(
widget.id,
ComicType(widget.sourceKey.hashCode),
);
history = await HistoryManager()
.find(widget.id, ComicType(widget.sourceKey.hashCode));
return comicSource!.loadComicInfo!(widget.id);
}
@override
onDataLoaded() {
isLiked = comic.isLiked ?? false;
isFavorite = comic.isFavorite ?? false;
}
Iterable<Widget> buildTitle() sync* {
yield SliverAppbar(
title: AnimatedOpacity(
opacity: showAppbarTitle ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: Text(comic.title),
),
actions: [
IconButton(
onPressed: showMoreActions, icon: const Icon(Icons.more_horiz))
],
);
yield Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 16),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
),
height: 144,
width: 144 * 0.72,
clipBehavior: Clip.antiAlias,
child: AnimatedImage(
image: CachedImageProvider(
comic.cover,
sourceKey: comic.sourceKey,
),
width: double.infinity,
height: double.infinity,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(comic.title, style: ts.s18),
if (comic.subTitle != null) Text(comic.subTitle!, style: ts.s14),
Text(
(ComicSource.find(comic.sourceKey)?.name) ?? '',
style: ts.s12,
),
],
),
),
],
).toSliver();
}
Widget buildActions() {
bool isMobile = context.width < changePoint;
return SliverToBoxAdapter(
child: Column(
children: [
ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
children: [
if(history != null && (history!.ep > 1 || history!.page > 1))
_ActionButton(
icon: const Icon(Icons.menu_book),
text: 'Continue'.tl,
onPressed: continueRead,
iconColor: context.useTextColor(Colors.yellow),
),
if (!isMobile)
_ActionButton(
icon: const Icon(Icons.play_circle_outline),
text: 'Read'.tl,
onPressed: read,
iconColor: context.useTextColor(Colors.orange),
),
if (!isMobile)
_ActionButton(
icon: const Icon(Icons.download),
text: 'Download'.tl,
onPressed: download,
iconColor: context.useTextColor(Colors.cyan),
),
if (data!.isLiked != null)
_ActionButton(
icon: const Icon(Icons.favorite_border),
activeIcon: const Icon(Icons.favorite),
isActive: isLiked,
text: (data!.likesCount ??
(isLiked ? 'Liked'.tl : 'Like'.tl))
.toString(),
isLoading: isLiking,
onPressed: likeOrUnlike,
iconColor: context.useTextColor(Colors.red),
),
_ActionButton(
icon: const Icon(Icons.bookmark_border),
activeIcon: const Icon(Icons.bookmark),
isActive: isFavorite || isAddToLocalFav,
text: 'Favorite'.tl,
onPressed: openFavPanel,
iconColor: context.useTextColor(Colors.purple),
),
if (comicSource.commentsLoader != null)
_ActionButton(
icon: const Icon(Icons.comment),
text: (comic.commentsCount ?? 'Comments'.tl).toString(),
onPressed: showComments,
iconColor: context.useTextColor(Colors.green),
),
_ActionButton(
icon: const Icon(Icons.share),
text: 'Share'.tl,
onPressed: share,
iconColor: context.useTextColor(Colors.blue),
),
],
).fixHeight(48),
if (isMobile)
Row(
children: [
Expanded(
child: FilledButton.tonal(
onPressed: () {},
child: Text("Download".tl),
),
),
const SizedBox(width: 16),
Expanded(
child: FilledButton(onPressed: read, child: Text("Read".tl)),
)
],
).paddingHorizontal(16).paddingVertical(8),
const Divider(),
],
).paddingTop(16),
);
}
Widget buildDescription() {
if (comic.description == null) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return SliverToBoxAdapter(
child: Column(
children: [
ListTile(
title: Text("Description".tl),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SelectableText(comic.description!),
),
const SizedBox(height: 16),
const Divider(),
],
),
);
}
Widget buildInfo() {
if (comic.tags.isEmpty &&
comic.uploader == null &&
comic.uploadTime == null &&
comic.uploadTime == null) {
return const SliverPadding(padding: EdgeInsets.zero);
}
int i = 0;
Widget buildTag({
required String text,
VoidCallback? onTap,
bool isTitle = false,
}) {
Color color;
if (isTitle) {
const colors = [
Colors.blue,
Colors.cyan,
Colors.red,
Colors.pink,
Colors.purple,
Colors.indigo,
Colors.teal,
Colors.green,
Colors.lime,
Colors.yellow,
];
color = context.useBackgroundColor(colors[(i++) % (colors.length)]);
} else {
color = context.colorScheme.surfaceContainer;
}
final borderRadius = BorderRadius.circular(12);
const padding = EdgeInsets.symmetric(horizontal: 16, vertical: 6);
if (onTap != null) {
return Material(
color: color,
borderRadius: borderRadius,
child: InkWell(
borderRadius: borderRadius,
onTap: onTap,
child: Text(text).padding(padding),
),
);
} else {
return Container(
decoration: BoxDecoration(
color: color,
borderRadius: borderRadius,
),
child: Text(text).padding(padding),
);
}
}
Widget buildWrap({required List<Widget> children}) {
return Wrap(
runSpacing: 8,
spacing: 8,
children: children,
).paddingHorizontal(16).paddingBottom(8);
}
return SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
title: Text("Information".tl),
),
for (var e in comic.tags.entries)
buildWrap(
children: [
buildTag(text: e.key, isTitle: true),
for (var tag in e.value)
buildTag(text: tag, onTap: () => onTagTap(tag, e.key)),
],
),
if (comic.uploader != null)
buildWrap(
children: [
buildTag(text: 'Uploader'.tl, isTitle: true),
buildTag(text: comic.uploader!),
],
),
if (comic.uploadTime != null)
buildWrap(
children: [
buildTag(text: 'Upload Time'.tl, isTitle: true),
buildTag(text: comic.uploadTime!),
],
),
if (comic.uploadTime != null)
buildWrap(
children: [
buildTag(text: 'Update Time'.tl, isTitle: true),
buildTag(text: comicSource.name),
],
),
const SizedBox(height: 12),
const Divider(),
],
),
);
}
Widget buildChapters() {
if (comic.chapters == null) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return const _ComicChapters();
}
Widget buildThumbnails() {
if (comic.thumbnails == null && comicSource.loadComicThumbnail == null) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return const _ComicThumbnails();
}
Widget buildRecommend() {
if (comic.recommend == null) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return SliverMainAxisGroup(slivers: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Related".tl),
),
),
SliverGridComics(comics: comic.recommend!),
]);
}
}
// TODO: Implement the _ComicPageActions mixin
abstract mixin class _ComicPageActions {
void update();
ComicDetails get comic;
ComicSource get comicSource => ComicSource.find(comic.sourceKey)!;
History? history;
bool isLiking = false;
bool isLiked = false;
void likeOrUnlike() async {
if(isLiking) return;
isLiking = true;
update();
var res = await comicSource.likeOrUnlikeComic!(comic.id, isLiked ?? false);
if(res.error) {
App.rootContext.showMessage(message: res.errorMessage!);
} else {
isLiked = !isLiked;
}
isLiking = false;
update();
}
bool isAddToLocalFav = false;
bool isFavorite = false;
void openFavPanel() {}
void share() {}
/// read the comic
///
/// [ep] the episode number, start from 1
///
/// [page] the page number, start from 1
void read([int? ep, int? page]) {}
void continueRead() {}
void download() {}
void onTagTap(String tag, String namespace) {}
void showMoreActions() {}
void showComments() {}
}
class _ActionButton extends StatelessWidget {
const _ActionButton({
required this.icon,
required this.text,
required this.onPressed,
this.activeIcon,
this.isActive,
this.isLoading,
this.iconColor,
});
final Widget icon;
final Widget? activeIcon;
final bool? isActive;
final String text;
final void Function() onPressed;
final bool? isLoading;
final Color? iconColor;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
child: InkWell(
onTap: () {
if(!(isLoading ?? false)) {
onPressed();
}
},
borderRadius: BorderRadius.circular(18),
child: IconTheme.merge(
data: IconThemeData(size: 20, color: iconColor),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isLoading ?? false)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 1.8),
)
else
(isActive ?? false) ? (activeIcon ?? icon) : icon,
const SizedBox(width: 8),
Text(text),
],
).paddingHorizontal(16),
),
),
);
}
}
class _ComicChapters extends StatefulWidget {
const _ComicChapters();
@override
State<_ComicChapters> createState() => _ComicChaptersState();
}
class _ComicChaptersState extends State<_ComicChapters> {
late _ComicPageState state;
bool reverse = false;
bool showAll = false;
@override
void didChangeDependencies() {
state = context.findAncestorStateOfType<_ComicPageState>()!;
super.didChangeDependencies();
}
@override
Widget build(BuildContext context) {
final eps = state.comic.chapters!;
int length = eps.length;
if (!showAll) {
length = math.min(length, 20);
}
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Chapters".tl),
trailing: Tooltip(
message: "Order".tl,
child: IconButton(
icon: Icon(reverse
? Icons.vertical_align_top
: Icons.vertical_align_bottom_outlined),
onPressed: () {
setState(() {
reverse = !reverse;
});
},
),
),
),
),
SliverGrid(
delegate:
SliverChildBuilderDelegate(childCount: length, (context, i) {
if (reverse) {
i = eps.length - i - 1;
}
var key = eps.keys.elementAt(i);
var value = eps[key]!;
bool visited =
(state.history?.readEpisode ?? const {}).contains(i + 1);
return Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Material(
elevation: 5,
color: context.colorScheme.surface,
surfaceTintColor: context.colorScheme.surfaceTint,
borderRadius: const BorderRadius.all(Radius.circular(12)),
shadowColor: Colors.transparent,
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Center(
child: Text(
value,
maxLines: 1,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color:
visited ? context.colorScheme.outline : null),
),
),
),
),
onTap: () => state.read(i + 1),
),
);
}),
gridDelegate: const SliverGridDelegateWithFixedHeight(
maxCrossAxisExtent: 200, itemHeight: 48),
),
if (eps.length > 20 && !showAll)
SliverToBoxAdapter(
child: Align(
alignment: Alignment.center,
child: FilledButton.tonal(
style: ButtonStyle(
shape: WidgetStateProperty.all(const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)))),
),
onPressed: () {
setState(() {
showAll = true;
});
},
child: Text("${"Show all".tl} (${eps.length})"),
).paddingTop(12),
),
),
const SliverToBoxAdapter(
child: Divider(),
),
],
);
}
}
class _ComicThumbnails extends StatefulWidget {
const _ComicThumbnails();
@override
State<_ComicThumbnails> createState() => _ComicThumbnailsState();
}
class _ComicThumbnailsState extends State<_ComicThumbnails> {
late _ComicPageState state;
late List<String> thumbnails;
bool isInitialLoading = false;
String? next;
@override
void didChangeDependencies() {
state = context.findAncestorStateOfType<_ComicPageState>()!;
thumbnails = List.from(state.comic.thumbnails ?? []);
super.didChangeDependencies();
}
bool isLoading = false;
void loadNext() async {
if(state.comicSource.loadComicThumbnail == null || isLoading) return;
if(!isInitialLoading && next == null) {
return;
}
setState(() {
isLoading = true;
});
var res = await state.comicSource.loadComicThumbnail!(state.comic.id, next);
if(res.success) {
thumbnails.addAll(res.data);
next = res.subData;
isInitialLoading = false;
}
setState(() {
isLoading = false;
});
}
@override
Widget build(BuildContext context) {
if(thumbnails.isEmpty) {
Future.microtask(loadNext);
}
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Preview".tl),
),
),
SliverGrid(
delegate: SliverChildBuilderDelegate(childCount: thumbnails.length,
(context, index) {
if (index == thumbnails.length - 1) {
loadNext();
}
return Padding(
padding: context.width < changePoint
? const EdgeInsets.all(4)
: const EdgeInsets.all(8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: InkWell(
onTap: () => state.read(null, index + 1),
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Container(
decoration: BoxDecoration(
borderRadius:
const BorderRadius.all(Radius.circular(16)),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
),
width: double.infinity,
height: double.infinity,
child: ClipRRect(
borderRadius:
const BorderRadius.all(Radius.circular(16)),
child: AnimatedImage(
image: CachedImageProvider(
thumbnails[index],
sourceKey: state.widget.sourceKey,
),
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
),
),
),
),
),
const SizedBox(
height: 4,
),
Text((index + 1).toString()),
],
),
);
}),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
childAspectRatio: 0.65,
),
),
if(isLoading)
const SliverToBoxAdapter(
child: ListLoadingIndicator(),
),
const SliverToBoxAdapter(
child: Divider(),
),
],
);
}
}