mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
comic list & explore page
This commit is contained in:
711
lib/components/comic.dart
Normal file
711
lib/components/comic.dart
Normal file
@@ -0,0 +1,711 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class ComicTile extends StatelessWidget {
|
||||
const ComicTile({
|
||||
super.key,
|
||||
required this.comic,
|
||||
this.enableLongPressed = true,
|
||||
this.badge,
|
||||
});
|
||||
|
||||
final Comic comic;
|
||||
|
||||
final bool enableLongPressed;
|
||||
|
||||
final String? badge;
|
||||
|
||||
void onTap() {}
|
||||
|
||||
void onLongPress() {}
|
||||
|
||||
void onSecondaryTap(TapDownDetails details) {}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var type = appdata.settings['comicDisplayMode'];
|
||||
|
||||
Widget child = type == 'detailed'
|
||||
? _buildDetailedMode(context)
|
||||
: _buildBriefMode(context);
|
||||
|
||||
var isFavorite = appdata.settings['showFavoriteStatusOnTile']
|
||||
? LocalFavoritesManager()
|
||||
.isExist(comic.id, ComicType(comic.sourceKey.hashCode))
|
||||
: false;
|
||||
var history = appdata.settings['showHistoryStatusOnTile']
|
||||
? HistoryManager()
|
||||
.findSync(comic.id, ComicType(comic.sourceKey.hashCode))
|
||||
: null;
|
||||
if (history?.page == 0) {
|
||||
history!.page = 1;
|
||||
}
|
||||
|
||||
if (!isFavorite && history == null) {
|
||||
return child;
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: child,
|
||||
),
|
||||
Positioned(
|
||||
left: type == 'detailed' ? 16 : 6,
|
||||
top: 8,
|
||||
child: Container(
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Row(
|
||||
children: [
|
||||
if (isFavorite)
|
||||
Container(
|
||||
height: 24,
|
||||
width: 24,
|
||||
color: Colors.green,
|
||||
child: const Icon(
|
||||
Icons.bookmark_rounded,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
if (history != null)
|
||||
Container(
|
||||
height: 24,
|
||||
color: Colors.blue.withOpacity(0.9),
|
||||
constraints: const BoxConstraints(minWidth: 24),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: CustomPaint(
|
||||
painter:
|
||||
_ReadingHistoryPainter(history.page, history.maxPage),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildImage(BuildContext context) {
|
||||
ImageProvider image;
|
||||
if (comic is LocalComic) {
|
||||
image = FileImage((comic as LocalComic).coverFile);
|
||||
} else {
|
||||
image = CachedImageProvider(comic.cover, sourceKey: comic.sourceKey);
|
||||
}
|
||||
return AnimatedImage(
|
||||
image: image,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailedMode(BuildContext context) {
|
||||
return LayoutBuilder(builder: (context, constrains) {
|
||||
final height = constrains.maxHeight - 16;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: onTap,
|
||||
onLongPress: enableLongPressed ? onLongPress : null,
|
||||
onSecondaryTapDown: onSecondaryTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 24, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: height * 0.68,
|
||||
height: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: buildImage(context),
|
||||
),
|
||||
SizedBox.fromSize(
|
||||
size: const Size(16, 5),
|
||||
),
|
||||
Expanded(
|
||||
child: _ComicDescription(
|
||||
title: comic.maxPage == null
|
||||
? comic.title.replaceAll("\n", "")
|
||||
: "[${comic.maxPage}P]${comic.title.replaceAll("\n", "")}",
|
||||
subtitle: comic.subtitle ?? '',
|
||||
description: comic.description,
|
||||
badge: badge,
|
||||
tags: comic.tags,
|
||||
maxLines: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildBriefMode(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
elevation: 1,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: buildImage(context),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withOpacity(0.3),
|
||||
Colors.black.withOpacity(0.5),
|
||||
]),
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(8),
|
||||
bottomRight: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
|
||||
child: Text(
|
||||
comic.title.replaceAll("\n", ""),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14.0,
|
||||
color: Colors.white,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
)),
|
||||
Positioned.fill(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onLongPress: enableLongPressed ? onLongPress : null,
|
||||
onSecondaryTapDown: onSecondaryTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ComicDescription extends StatelessWidget {
|
||||
const _ComicDescription(
|
||||
{required this.title,
|
||||
required this.subtitle,
|
||||
required this.description,
|
||||
this.badge,
|
||||
this.maxLines = 2,
|
||||
this.tags});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String description;
|
||||
final String? badge;
|
||||
final List<String>? tags;
|
||||
final int maxLines;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (tags != null) {
|
||||
tags!.removeWhere((element) => element.removeAllBlank == "");
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14.0,
|
||||
),
|
||||
maxLines: maxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (subtitle != "")
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(fontSize: 10.0),
|
||||
maxLines: 1,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
if (tags != null)
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => Padding(
|
||||
padding: EdgeInsets.only(bottom: constraints.maxHeight % 23),
|
||||
child: Wrap(
|
||||
runAlignment: WrapAlignment.start,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
crossAxisAlignment: WrapCrossAlignment.end,
|
||||
children: [
|
||||
for (var s in tags!)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 0, 4, 3),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(3, 1, 3, 3),
|
||||
decoration: BoxDecoration(
|
||||
color: s == "Unavailable"
|
||||
? Theme.of(context).colorScheme.errorContainer
|
||||
: Theme.of(context)
|
||||
.colorScheme
|
||||
.secondaryContainer,
|
||||
borderRadius:
|
||||
const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
child: Text(
|
||||
s,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 2,
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
description,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (badge != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.tertiaryContainer,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
child: Text(
|
||||
badge!,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReadingHistoryPainter extends CustomPainter {
|
||||
final int page;
|
||||
final int? maxPage;
|
||||
|
||||
const _ReadingHistoryPainter(this.page, this.maxPage);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (maxPage == null) {
|
||||
// 在中央绘制page
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: "$page",
|
||||
style: TextStyle(
|
||||
fontSize: size.width * 0.8,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
textPainter.layout();
|
||||
textPainter.paint(
|
||||
canvas,
|
||||
Offset((size.width - textPainter.width) / 2,
|
||||
(size.height - textPainter.height) / 2));
|
||||
} else if (page == maxPage) {
|
||||
// 在中央绘制勾
|
||||
final paint = Paint()
|
||||
..color = Colors.white
|
||||
..strokeWidth = 2
|
||||
..style = PaintingStyle.stroke;
|
||||
canvas.drawLine(Offset(size.width * 0.2, size.height * 0.5),
|
||||
Offset(size.width * 0.45, size.height * 0.75), paint);
|
||||
canvas.drawLine(Offset(size.width * 0.45, size.height * 0.75),
|
||||
Offset(size.width * 0.85, size.height * 0.3), paint);
|
||||
} else {
|
||||
// 在左上角绘制page, 在右下角绘制maxPage
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: "$page",
|
||||
style: TextStyle(
|
||||
fontSize: size.width * 0.8,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
textPainter.layout();
|
||||
textPainter.paint(canvas, const Offset(0, 0));
|
||||
final textPainter2 = TextPainter(
|
||||
text: TextSpan(
|
||||
text: "/$maxPage",
|
||||
style: TextStyle(
|
||||
fontSize: size.width * 0.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
textPainter2.layout();
|
||||
textPainter2.paint(
|
||||
canvas,
|
||||
Offset(size.width - textPainter2.width,
|
||||
size.height - textPainter2.height));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) {
|
||||
return oldDelegate is! _ReadingHistoryPainter ||
|
||||
oldDelegate.page != page ||
|
||||
oldDelegate.maxPage != maxPage;
|
||||
}
|
||||
}
|
||||
|
||||
class SliverGridComicsController extends StateController {}
|
||||
|
||||
class SliverGridComics extends StatelessWidget {
|
||||
const SliverGridComics({
|
||||
super.key,
|
||||
required this.comics,
|
||||
this.onLastItemBuild,
|
||||
});
|
||||
|
||||
final List<Comic> comics;
|
||||
|
||||
final void Function()? onLastItemBuild;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StateBuilder<SliverGridComicsController>(
|
||||
init: SliverGridComicsController(),
|
||||
builder: (controller) {
|
||||
List<Comic> comics = [];
|
||||
for (var comic in this.comics) {
|
||||
if (isBlocked(comic) == null) {
|
||||
comics.add(comic);
|
||||
}
|
||||
}
|
||||
return _SliverGridComics(
|
||||
comics: comics,
|
||||
onLastItemBuild: onLastItemBuild,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SliverGridComics extends StatelessWidget {
|
||||
const _SliverGridComics({
|
||||
required this.comics,
|
||||
this.onLastItemBuild,
|
||||
});
|
||||
|
||||
final List<Comic> comics;
|
||||
|
||||
final void Function()? onLastItemBuild;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverGrid(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index == comics.length - 1) {
|
||||
onLastItemBuild?.call();
|
||||
}
|
||||
return ComicTile(comic: comics[index]);
|
||||
},
|
||||
childCount: comics.length,
|
||||
),
|
||||
gridDelegate: SliverGridDelegateWithComics(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// return the first blocked keyword, or null if not blocked
|
||||
String? isBlocked(Comic item) {
|
||||
for (var word in appdata.settings['blockedWords']) {
|
||||
if (item.title.contains(word)) {
|
||||
return word;
|
||||
}
|
||||
if (item.subtitle?.contains(word) ?? false) {
|
||||
return word;
|
||||
}
|
||||
if (item.description.contains(word)) {
|
||||
return word;
|
||||
}
|
||||
for (var tag in item.tags ?? <String>[]) {
|
||||
if (tag == word) {
|
||||
return word;
|
||||
}
|
||||
if (tag.contains(':')) {
|
||||
tag = tag.split(':')[1];
|
||||
if (tag == word) {
|
||||
return word;
|
||||
}
|
||||
}
|
||||
// TODO: check translated tags
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
class ComicList extends StatefulWidget {
|
||||
const ComicList({super.key, this.loadPage, this.loadNext});
|
||||
|
||||
final Future<Res<List<Comic>>> Function(int page)? loadPage;
|
||||
|
||||
final Future<Res<List<Comic>>> Function(String? next)? loadNext;
|
||||
|
||||
@override
|
||||
State<ComicList> createState() => _ComicListState();
|
||||
}
|
||||
|
||||
class _ComicListState extends State<ComicList> {
|
||||
int? maxPage;
|
||||
|
||||
Map<int, List<Comic>> data = {};
|
||||
|
||||
int page = 1;
|
||||
|
||||
String? error;
|
||||
|
||||
Map<int, bool> loading = {};
|
||||
|
||||
String? nextUrl;
|
||||
|
||||
Widget buildPageSelector() {
|
||||
return Row(
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: page > 1
|
||||
? () {
|
||||
setState(() {
|
||||
error = null;
|
||||
page--;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
child: Text("Back".tl),
|
||||
).fixWidth(84),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () {
|
||||
String value = '';
|
||||
showDialog(
|
||||
context: App.rootContext,
|
||||
builder: (context) {
|
||||
return ContentDialog(
|
||||
title: "Jump to page".tl,
|
||||
content: TextField(
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Page".tl,
|
||||
),
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.digitsOnly
|
||||
],
|
||||
onChanged: (v) {
|
||||
value = v;
|
||||
},
|
||||
).paddingHorizontal(16),
|
||||
actions: [
|
||||
Button.filled(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
var page = int.tryParse(value);
|
||||
if(page == null) {
|
||||
context.showMessage(message: "Invalid page".tl);
|
||||
} else {
|
||||
if(page > 0 && (maxPage == null || page <= maxPage!)) {
|
||||
setState(() {
|
||||
error = null;
|
||||
this.page = page;
|
||||
});
|
||||
} else {
|
||||
context.showMessage(message: "Invalid page".tl);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Text("Jump".tl),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: Text("Page $page / ${maxPage ?? '?'}"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: page < (maxPage ?? (page + 1))
|
||||
? () {
|
||||
setState(() {
|
||||
error = null;
|
||||
page++;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
child: Text("Next".tl),
|
||||
).fixWidth(84),
|
||||
],
|
||||
).paddingVertical(8).paddingHorizontal(16);
|
||||
}
|
||||
|
||||
Widget buildSliverPageSelector() {
|
||||
return SliverToBoxAdapter(
|
||||
child: buildPageSelector(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> loadPage(int page) async {
|
||||
if (loading[page] == true) {
|
||||
return;
|
||||
}
|
||||
loading[page] = true;
|
||||
try {
|
||||
if (widget.loadPage != null) {
|
||||
var res = await widget.loadPage!(page);
|
||||
if (res.success) {
|
||||
if (res.data.isEmpty) {
|
||||
data[page] = const [];
|
||||
setState(() {
|
||||
maxPage = page;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
data[page] = res.data;
|
||||
if (res.subData?['maxPage'] != null) {
|
||||
maxPage = res.subData['maxPage'];
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
error = res.errorMessage ?? "Unknown error".tl;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
while (data[page] == null) {
|
||||
await fetchNext();
|
||||
}
|
||||
setState(() {});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
error = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading[page] = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchNext() async {
|
||||
var res = await widget.loadNext!(nextUrl);
|
||||
data[data.length + 1] = res.data;
|
||||
if (res.subData['next'] == null) {
|
||||
maxPage = data.length;
|
||||
} else {
|
||||
nextUrl = res.subData['next'];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.loadPage == null && widget.loadNext == null) {
|
||||
throw Exception("loadPage and loadNext can't be null at the same time");
|
||||
}
|
||||
if (error != null) {
|
||||
return Column(
|
||||
children: [
|
||||
buildPageSelector(),
|
||||
Expanded(
|
||||
child: NetworkError(
|
||||
withAppbar: false,
|
||||
message: error!,
|
||||
retry: () {
|
||||
setState(() {
|
||||
error = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
if (data[page] == null) {
|
||||
loadPage(page);
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
return SmoothCustomScrollView(
|
||||
slivers: [
|
||||
buildSliverPageSelector(),
|
||||
SliverGridComics(comics: data[page] ?? const []),
|
||||
buildSliverPageSelector(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@@ -2,6 +2,7 @@ library components;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
|
||||
@@ -13,9 +14,17 @@ import 'package:flutter/services.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/app_page_route.dart';
|
||||
import 'package:venera/foundation/appdata.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/local.dart';
|
||||
import 'package:venera/foundation/res.dart';
|
||||
import 'package:venera/foundation/state_controller.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
part 'image.dart';
|
||||
@@ -32,3 +41,4 @@ part 'pop_up_widget.dart';
|
||||
part 'scroll.dart';
|
||||
part 'select.dart';
|
||||
part 'side_bar.dart';
|
||||
part 'comic.dart';
|
@@ -15,18 +15,19 @@ class FlyoutController {
|
||||
}
|
||||
|
||||
class Flyout extends StatefulWidget {
|
||||
const Flyout(
|
||||
{super.key,
|
||||
required this.flyoutBuilder,
|
||||
required this.child,
|
||||
this.enableTap = false,
|
||||
this.enableDoubleTap = false,
|
||||
this.enableLongPress = false,
|
||||
this.enableSecondaryTap = false,
|
||||
this.withInkWell = false,
|
||||
this.borderRadius = 0,
|
||||
this.controller,
|
||||
this.navigator});
|
||||
const Flyout({
|
||||
super.key,
|
||||
required this.flyoutBuilder,
|
||||
required this.child,
|
||||
this.enableTap = false,
|
||||
this.enableDoubleTap = false,
|
||||
this.enableLongPress = false,
|
||||
this.enableSecondaryTap = false,
|
||||
this.withInkWell = false,
|
||||
this.borderRadius = 0,
|
||||
this.controller,
|
||||
this.navigator,
|
||||
});
|
||||
|
||||
final WidgetBuilder flyoutBuilder;
|
||||
|
||||
@@ -164,7 +165,7 @@ class FlyoutContent extends StatelessWidget {
|
||||
|
||||
final String title;
|
||||
|
||||
final String? content;
|
||||
final Widget? content;
|
||||
|
||||
final List<Widget> actions;
|
||||
|
||||
@@ -191,7 +192,7 @@ class FlyoutContent extends StatelessWidget {
|
||||
if (content != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(content!, style: const TextStyle(fontSize: 12)),
|
||||
child: content!,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
|
@@ -20,12 +20,25 @@ class NetworkError extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 60,
|
||||
Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 28,
|
||||
color: context.colorScheme.error,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"Error".tl,
|
||||
style: ts.withColor(context.colorScheme.error).s16,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
height: 8,
|
||||
),
|
||||
Text(
|
||||
message,
|
||||
@@ -34,7 +47,7 @@ class NetworkError extends StatelessWidget {
|
||||
),
|
||||
if (retry != null)
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
height: 12,
|
||||
),
|
||||
if (retry != null)
|
||||
FilledButton(onPressed: retry, child: Text('重试'.tl))
|
||||
|
@@ -206,14 +206,6 @@ class _NaviPaneState extends State<NaviPane>
|
||||
padding: const EdgeInsets.only(left: 16, right: 16),
|
||||
height: _kTopBarHeight,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
|
@@ -11,6 +11,13 @@ class _Appdata {
|
||||
var file = File(FilePath.join(App.dataPath, 'settings.json'));
|
||||
await file.writeAsString(data);
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
var json = jsonDecode(await File(FilePath.join(App.dataPath, 'settings.json')).readAsString()) as Map<String, dynamic>;
|
||||
for(var key in json.keys) {
|
||||
settings[key] = json[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final appdata = _Appdata();
|
||||
@@ -29,6 +36,9 @@ class _Settings {
|
||||
'explore_pages': [],
|
||||
'categories': [],
|
||||
'favorites': [],
|
||||
'showFavoriteStatusOnTile': true,
|
||||
'showHistoryStatusOnTile': false,
|
||||
'blockedWords': [],
|
||||
};
|
||||
|
||||
operator[](String key) {
|
||||
|
@@ -389,7 +389,7 @@ class Comic {
|
||||
|
||||
final String id;
|
||||
|
||||
final String? subTitle;
|
||||
final String? subtitle;
|
||||
|
||||
final List<String>? tags;
|
||||
|
||||
@@ -397,27 +397,31 @@ class Comic {
|
||||
|
||||
final String sourceKey;
|
||||
|
||||
const Comic(this.title, this.cover, this.id, this.subTitle, this.tags, this.description, this.sourceKey);
|
||||
final int? maxPage;
|
||||
|
||||
const Comic(this.title, this.cover, this.id, this.subtitle, this.tags, this.description, this.sourceKey, this.maxPage);
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"title": title,
|
||||
"cover": cover,
|
||||
"id": id,
|
||||
"subTitle": subTitle,
|
||||
"subTitle": subtitle,
|
||||
"tags": tags,
|
||||
"description": description,
|
||||
"sourceKey": sourceKey,
|
||||
"maxPage": maxPage,
|
||||
};
|
||||
}
|
||||
|
||||
Comic.fromJson(Map<String, dynamic> json, this.sourceKey)
|
||||
: title = json["title"],
|
||||
subTitle = json["subTitle"] ?? "",
|
||||
subtitle = json["subTitle"] ?? "",
|
||||
cover = json["cover"],
|
||||
id = json["id"],
|
||||
tags = List<String>.from(json["tags"] ?? []),
|
||||
description = json["description"] ?? "";
|
||||
description = json["description"] ?? "",
|
||||
maxPage = json["maxPage"];
|
||||
}
|
||||
|
||||
class ComicDetails with HistoryMixin {
|
||||
|
@@ -455,11 +455,11 @@ class LocalFavoritesManager {
|
||||
|
||||
final _cachedFavoritedIds = <String, bool>{};
|
||||
|
||||
bool isExist(String id) {
|
||||
bool isExist(String id, ComicType type) {
|
||||
if (_modifiedAfterLastCache) {
|
||||
_cacheFavoritedIds();
|
||||
}
|
||||
return _cachedFavoritedIds.containsKey(id);
|
||||
return _cachedFavoritedIds.containsKey("$id@${type.value}");
|
||||
}
|
||||
|
||||
bool _modifiedAfterLastCache = true;
|
||||
@@ -468,11 +468,11 @@ class LocalFavoritesManager {
|
||||
_modifiedAfterLastCache = false;
|
||||
_cachedFavoritedIds.clear();
|
||||
for (var folder in folderNames) {
|
||||
var res = _db.select("""
|
||||
select id from "$folder";
|
||||
var rows = _db.select("""
|
||||
select id, type from "$folder";
|
||||
""");
|
||||
for (var row in res) {
|
||||
_cachedFavoritedIds[row["id"]] = true;
|
||||
for (var row in rows) {
|
||||
_cachedFavoritedIds["${row["id"]}@${row["type"]}"] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,12 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'log.dart';
|
||||
|
||||
typedef HistoryType = ComicType;
|
||||
|
||||
@@ -113,7 +111,7 @@ class History {
|
||||
int ep = 0,
|
||||
int page = 0,
|
||||
}) async {
|
||||
var history = await HistoryManager().find(model.id);
|
||||
var history = await HistoryManager().find(model.id, model.historyType);
|
||||
if (history != null) {
|
||||
return history;
|
||||
}
|
||||
@@ -147,36 +145,6 @@ class HistoryManager with ChangeNotifier {
|
||||
|
||||
Map<String, bool>? _cachedHistory;
|
||||
|
||||
Future<void> tryUpdateDb() async {
|
||||
var file = File("${App.dataPath}/history_temp.db");
|
||||
if (!file.existsSync()) {
|
||||
Log.info("HistoryManager.tryUpdateDb", "db file not exist");
|
||||
return;
|
||||
}
|
||||
var db = sqlite3.open(file.path);
|
||||
var newHistory0 = db.select("""
|
||||
select * from history
|
||||
order by time DESC;
|
||||
""");
|
||||
var newHistory =
|
||||
newHistory0.map((element) => History.fromRow(element)).toList();
|
||||
if (file.existsSync()) {
|
||||
var skips = 0;
|
||||
for (var history in newHistory) {
|
||||
if (findSync(history.id) == null) {
|
||||
addHistory(history);
|
||||
Log.info("HistoryManager", "merge history ${history.id}");
|
||||
} else {
|
||||
skips++;
|
||||
}
|
||||
}
|
||||
Log.info("HistoryManager",
|
||||
"merge history, skipped $skips, added ${newHistory.length - skips}");
|
||||
}
|
||||
db.dispose();
|
||||
file.deleteSync();
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
_db = sqlite3.open("${App.dataPath}/history.db");
|
||||
|
||||
@@ -202,8 +170,8 @@ class HistoryManager with ChangeNotifier {
|
||||
Future<void> addHistory(History newItem) async {
|
||||
var res = _db.select("""
|
||||
select * from history
|
||||
where id == ?;
|
||||
""", [newItem.id]);
|
||||
where id == ? and type == ?;
|
||||
""", [newItem.id, newItem.type.value]);
|
||||
if (res.isEmpty) {
|
||||
_db.execute("""
|
||||
insert into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page)
|
||||
@@ -224,8 +192,8 @@ class HistoryManager with ChangeNotifier {
|
||||
_db.execute("""
|
||||
update history
|
||||
set time = ${DateTime.now().millisecondsSinceEpoch}
|
||||
where id == ?;
|
||||
""", [newItem.id]);
|
||||
where id == ? and type == ?;
|
||||
""", [newItem.id, newItem.type.value]);
|
||||
}
|
||||
updateCache();
|
||||
notifyListeners();
|
||||
@@ -235,13 +203,14 @@ class HistoryManager with ChangeNotifier {
|
||||
_db.execute("""
|
||||
update history
|
||||
set time = ${DateTime.now().millisecondsSinceEpoch}, ep = ?, page = ?, readEpisode = ?, max_page = ?
|
||||
where id == ?;
|
||||
where id == ? and type == ?;
|
||||
""", [
|
||||
history.ep,
|
||||
history.page,
|
||||
history.readEpisode.join(','),
|
||||
history.maxPage,
|
||||
history.id
|
||||
history.id,
|
||||
history.type.value
|
||||
]);
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -251,16 +220,16 @@ class HistoryManager with ChangeNotifier {
|
||||
updateCache();
|
||||
}
|
||||
|
||||
void remove(String id) async {
|
||||
void remove(String id, ComicType type) async {
|
||||
_db.execute("""
|
||||
delete from history
|
||||
where id == '$id';
|
||||
""");
|
||||
where id == ? and type == ?;
|
||||
""", [id, type.value]);
|
||||
updateCache();
|
||||
}
|
||||
|
||||
Future<History?> find(String id) async {
|
||||
return findSync(id);
|
||||
Future<History?> find(String id, ComicType type) async {
|
||||
return findSync(id, type);
|
||||
}
|
||||
|
||||
void updateCache() {
|
||||
@@ -273,7 +242,7 @@ class HistoryManager with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
History? findSync(String id) {
|
||||
History? findSync(String id, ComicType type) {
|
||||
if(_cachedHistory == null) {
|
||||
updateCache();
|
||||
}
|
||||
@@ -283,8 +252,8 @@ class HistoryManager with ChangeNotifier {
|
||||
|
||||
var res = _db.select("""
|
||||
select * from history
|
||||
where id == ?;
|
||||
""", [id]);
|
||||
where id == ? and type == ?;
|
||||
""", [id, type.value]);
|
||||
if (res.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
@@ -4,17 +4,22 @@ import 'dart:io';
|
||||
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
|
||||
import 'app.dart';
|
||||
|
||||
class LocalComic {
|
||||
final int id;
|
||||
class LocalComic implements Comic{
|
||||
@override
|
||||
final String id;
|
||||
|
||||
@override
|
||||
final String title;
|
||||
|
||||
@override
|
||||
final String subtitle;
|
||||
|
||||
@override
|
||||
final List<String> tags;
|
||||
|
||||
/// name of the directory, which is in `LocalManager.path`
|
||||
@@ -26,6 +31,7 @@ class LocalComic {
|
||||
final Map<String, String>? chapters;
|
||||
|
||||
/// relative path to the cover image
|
||||
@override
|
||||
final String cover;
|
||||
|
||||
final ComicType comicType;
|
||||
@@ -45,7 +51,7 @@ class LocalComic {
|
||||
});
|
||||
|
||||
LocalComic.fromRow(Row row)
|
||||
: id = row[0] as int,
|
||||
: id = row[0] as String,
|
||||
title = row[1] as String,
|
||||
subtitle = row[2] as String,
|
||||
tags = List.from(jsonDecode(row[3] as String)),
|
||||
@@ -56,6 +62,28 @@ class LocalComic {
|
||||
createdAt = DateTime.fromMillisecondsSinceEpoch(row[8] as int);
|
||||
|
||||
File get coverFile => File('${LocalManager().path}/$directory/$cover');
|
||||
|
||||
@override
|
||||
String get description => "";
|
||||
|
||||
@override
|
||||
String get sourceKey => comicType.comicSource?.key ?? '_local_';
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"title": title,
|
||||
"cover": cover,
|
||||
"id": id,
|
||||
"subTitle": subtitle,
|
||||
"tags": tags,
|
||||
"description": description,
|
||||
"sourceKey": sourceKey,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
int? get maxPage => null;
|
||||
}
|
||||
|
||||
class LocalManager with ChangeNotifier {
|
||||
@@ -77,7 +105,7 @@ class LocalManager with ChangeNotifier {
|
||||
);
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS comics (
|
||||
id INTEGER,
|
||||
id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
subtitle TEXT NOT NULL,
|
||||
tags TEXT NOT NULL,
|
||||
@@ -108,18 +136,21 @@ class LocalManager with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
int findValidId(ComicType type) {
|
||||
final res = _db.select(
|
||||
'SELECT id FROM comics WHERE comic_type = ? ORDER BY id DESC LIMIT 1;',
|
||||
String findValidId(ComicType type) {
|
||||
final res = _db.select('''
|
||||
SELECT id FROM comics WHERE comic_type = ?
|
||||
ORDER BY CAST(id AS INTEGER) DESC
|
||||
LIMIT 1;
|
||||
'''
|
||||
[type.value],
|
||||
);
|
||||
if (res.isEmpty) {
|
||||
return 1;
|
||||
return '1';
|
||||
}
|
||||
return (res.first[0] as int) + 1;
|
||||
return ((res.first[0] as int) + 1).toString();
|
||||
}
|
||||
|
||||
Future<void> add(LocalComic comic, [int? id]) async {
|
||||
Future<void> add(LocalComic comic, [String? id]) async {
|
||||
_db.execute(
|
||||
'INSERT INTO comics VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);',
|
||||
[
|
||||
@@ -137,7 +168,7 @@ class LocalManager with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void remove(int id, ComicType comicType) async {
|
||||
void remove(String id, ComicType comicType) async {
|
||||
_db.execute(
|
||||
'DELETE FROM comics WHERE id = ? AND comic_type = ?;',
|
||||
[id, comicType.value],
|
||||
@@ -155,7 +186,7 @@ class LocalManager with ChangeNotifier {
|
||||
return res.map((row) => LocalComic.fromRow(row)).toList();
|
||||
}
|
||||
|
||||
LocalComic? find(int id, ComicType comicType) {
|
||||
LocalComic? find(String id, ComicType comicType) {
|
||||
final res = _db.select(
|
||||
'SELECT * FROM comics WHERE id = ? AND comic_type = ?;',
|
||||
[id, comicType.value],
|
||||
|
@@ -64,6 +64,10 @@ extension WidgetExtension on Widget{
|
||||
Widget fixHeight(double height){
|
||||
return SizedBox(height: height, child: this);
|
||||
}
|
||||
|
||||
Widget toSliver(){
|
||||
return SliverToBoxAdapter(child: this);
|
||||
}
|
||||
}
|
||||
|
||||
/// create default text style
|
||||
|
@@ -8,9 +8,12 @@ import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/network/cookie_jar.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
import 'foundation/appdata.dart';
|
||||
|
||||
Future<void> init() async {
|
||||
await AppTranslation.init();
|
||||
await App.init();
|
||||
await appdata.init();
|
||||
await HistoryManager().init();
|
||||
await LocalManager().init();
|
||||
await LocalFavoritesManager().init();
|
||||
|
@@ -155,7 +155,7 @@ class _BodyState extends State<_Body> {
|
||||
() {
|
||||
var file = File(source.filePath);
|
||||
file.delete();
|
||||
ComicSource.all().remove(source);
|
||||
ComicSource.remove(source.key);
|
||||
_validatePages();
|
||||
App.forceRebuild();
|
||||
},
|
||||
|
@@ -1,10 +1,392 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/components/components.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/res.dart';
|
||||
import 'package:venera/foundation/state_controller.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
class ExplorePage extends StatelessWidget {
|
||||
class ExplorePage extends StatefulWidget {
|
||||
const ExplorePage({super.key});
|
||||
|
||||
@override
|
||||
State<ExplorePage> createState() => _ExplorePageState();
|
||||
}
|
||||
|
||||
class _ExplorePageState extends State<ExplorePage>
|
||||
with TickerProviderStateMixin {
|
||||
late TabController controller;
|
||||
|
||||
bool showFB = true;
|
||||
|
||||
double location = 0;
|
||||
|
||||
late List<String> pages;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
pages = List<String>.from(appdata.settings["explore_pages"]);
|
||||
var all = ComicSource.all().map((e) => e.explorePages).expand((e) => e.map((e) => e.title)).toList();
|
||||
pages = pages.where((e) => all.contains(e)).toList();
|
||||
controller = TabController(
|
||||
length: pages.length,
|
||||
vsync: this,
|
||||
);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void refresh() {
|
||||
int page = controller.index;
|
||||
String currentPageId = pages[page];
|
||||
StateController.find<SimpleController>(tag: currentPageId).refresh();
|
||||
}
|
||||
|
||||
Widget buildFAB() => Material(
|
||||
color: Colors.transparent,
|
||||
child: FloatingActionButton(
|
||||
key: const Key("FAB"),
|
||||
onPressed: refresh,
|
||||
child: const Icon(Icons.refresh),
|
||||
),
|
||||
);
|
||||
|
||||
Tab buildTab(String i) {
|
||||
return Tab(text: i.tl, key: Key(i));
|
||||
}
|
||||
|
||||
Widget buildBody(String i) => _SingleExplorePage(i, key: Key(i));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Placeholder();
|
||||
Widget tabBar = Material(
|
||||
child: FilledTabBar(
|
||||
tabs: pages.map((e) => buildTab(e)).toList(),
|
||||
controller: controller,
|
||||
),
|
||||
);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Column(
|
||||
children: [
|
||||
tabBar,
|
||||
Expanded(
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: (notifications) {
|
||||
if (notifications.metrics.axis == Axis.horizontal) {
|
||||
if (!showFB) {
|
||||
setState(() {
|
||||
showFB = true;
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
var current = notifications.metrics.pixels;
|
||||
|
||||
if ((current > location && current != 0) && showFB) {
|
||||
setState(() {
|
||||
showFB = false;
|
||||
});
|
||||
} else if ((current < location || current == 0) && !showFB) {
|
||||
setState(() {
|
||||
showFB = true;
|
||||
});
|
||||
}
|
||||
|
||||
location = current;
|
||||
return false;
|
||||
},
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: TabBarView(
|
||||
controller: controller,
|
||||
children: pages
|
||||
.map((e) => buildBody(e))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
)),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
reverseDuration: const Duration(milliseconds: 150),
|
||||
child: showFB ? buildFAB() : const SizedBox(),
|
||||
transitionBuilder: (widget, animation) {
|
||||
var tween = Tween<Offset>(
|
||||
begin: const Offset(0, 1), end: const Offset(0, 0));
|
||||
return SlideTransition(
|
||||
position: tween.animate(animation),
|
||||
child: widget,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleExplorePage extends StatefulWidget {
|
||||
const _SingleExplorePage(this.title, {super.key});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<_SingleExplorePage> createState() => _SingleExplorePageState();
|
||||
}
|
||||
|
||||
class _SingleExplorePageState extends StateWithController<_SingleExplorePage> {
|
||||
late final ExplorePageData data;
|
||||
|
||||
bool loading = true;
|
||||
|
||||
String? message;
|
||||
|
||||
List<ExplorePagePart>? parts;
|
||||
|
||||
late final String comicSourceKey;
|
||||
|
||||
int key = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (var source in ComicSource.all()) {
|
||||
for (var d in source.explorePages) {
|
||||
if (d.title == widget.title) {
|
||||
data = d;
|
||||
comicSourceKey = source.key;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw "Explore Page ${widget.title} Not Found!";
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (data.loadMultiPart != null) {
|
||||
return buildMultiPart();
|
||||
} else if (data.loadPage != null) {
|
||||
return buildComicList();
|
||||
} else if (data.loadMixed != null) {
|
||||
return _MixedExplorePage(
|
||||
data,
|
||||
comicSourceKey,
|
||||
key: ValueKey(key),
|
||||
);
|
||||
} else if (data.overridePageBuilder != null) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
return data.overridePageBuilder!(context);
|
||||
},
|
||||
key: ValueKey(key),
|
||||
);
|
||||
} else {
|
||||
return const Center(
|
||||
child: Text("Empty Page"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildComicList() {
|
||||
return ComicList(
|
||||
loadPage: data.loadPage!,
|
||||
key: ValueKey(key),
|
||||
);
|
||||
}
|
||||
|
||||
void load() async {
|
||||
var res = await data.loadMultiPart!();
|
||||
loading = false;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (res.error) {
|
||||
message = res.errorMessage;
|
||||
} else {
|
||||
parts = res.data;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildMultiPart() {
|
||||
if (loading) {
|
||||
load();
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
} else if (message != null) {
|
||||
return NetworkError(
|
||||
message: message!,
|
||||
retry: refresh,
|
||||
withAppbar: false,
|
||||
);
|
||||
} else {
|
||||
return buildPage();
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildPage() {
|
||||
return SmoothCustomScrollView(
|
||||
slivers: _buildPage().toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Widget> _buildPage() sync* {
|
||||
for (var part in parts!) {
|
||||
yield* _buildExplorePagePart(part, comicSourceKey);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Object? get tag => widget.title;
|
||||
|
||||
@override
|
||||
void refresh() {
|
||||
message = null;
|
||||
if (data.loadMultiPart != null) {
|
||||
setState(() {
|
||||
loading = true;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
key++;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MixedExplorePage extends StatefulWidget {
|
||||
const _MixedExplorePage(this.data, this.sourceKey, {super.key});
|
||||
|
||||
final ExplorePageData data;
|
||||
|
||||
final String sourceKey;
|
||||
|
||||
@override
|
||||
State<_MixedExplorePage> createState() => _MixedExplorePageState();
|
||||
}
|
||||
|
||||
class _MixedExplorePageState
|
||||
extends MultiPageLoadingState<_MixedExplorePage, Object> {
|
||||
Iterable<Widget> buildSlivers(BuildContext context, List<Object> data) sync* {
|
||||
List<Comic> cache = [];
|
||||
for (var part in data) {
|
||||
if (part is ExplorePagePart) {
|
||||
if (cache.isNotEmpty) {
|
||||
yield SliverGridComics(
|
||||
comics: (cache),
|
||||
);
|
||||
yield const SliverToBoxAdapter(child: Divider());
|
||||
cache.clear();
|
||||
}
|
||||
yield* _buildExplorePagePart(part, widget.sourceKey);
|
||||
yield const SliverToBoxAdapter(child: Divider());
|
||||
} else {
|
||||
cache.addAll(part as List<Comic>);
|
||||
}
|
||||
}
|
||||
if (cache.isNotEmpty) {
|
||||
yield SliverGridComics(
|
||||
comics: (cache),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildContent(BuildContext context, List<Object> data) {
|
||||
return SmoothCustomScrollView(
|
||||
slivers: [
|
||||
...buildSlivers(context, data),
|
||||
if (haveNextPage) const ListLoadingIndicator().toSliver()
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Res<List<Object>>> loadData(int page) async {
|
||||
var res = await widget.data.loadMixed!(page);
|
||||
if (res.error) {
|
||||
return res;
|
||||
}
|
||||
for (var element in res.data) {
|
||||
if (element is! ExplorePagePart && element is! List<Comic>) {
|
||||
return const Res.error("function loadMixed return invalid data");
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
Iterable<Widget> _buildExplorePagePart(
|
||||
ExplorePagePart part, String sourceKey) sync* {
|
||||
Widget buildTitle(ExplorePagePart part) {
|
||||
return SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 60,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 5, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
part.title,
|
||||
style:
|
||||
const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const Spacer(),
|
||||
if (part.viewMore != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// TODO: view more
|
||||
/*
|
||||
var context = App.mainNavigatorKey!.currentContext!;
|
||||
if (part.viewMore!.startsWith("search:")) {
|
||||
context.to(
|
||||
() => SearchResultPage(
|
||||
keyword: part.viewMore!.replaceFirst("search:", ""),
|
||||
sourceKey: sourceKey,
|
||||
),
|
||||
);
|
||||
} else if (part.viewMore!.startsWith("category:")) {
|
||||
var cp = part.viewMore!.replaceFirst("category:", "");
|
||||
var c = cp.split('@').first;
|
||||
String? p = cp.split('@').last;
|
||||
if (p == c) {
|
||||
p = null;
|
||||
}
|
||||
context.to(
|
||||
() => CategoryComicsPage(
|
||||
category: c,
|
||||
categoryKey:
|
||||
ComicSource.find(sourceKey)!.categoryData!.key,
|
||||
param: p,
|
||||
),
|
||||
);
|
||||
}*/
|
||||
},
|
||||
child: Text("查看更多".tl),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildComics(ExplorePagePart part) {
|
||||
return SliverGridComics(comics: part.comics);
|
||||
}
|
||||
|
||||
yield buildTitle(part);
|
||||
yield buildComics(part);
|
||||
}
|
||||
|
@@ -539,7 +539,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
|
||||
return null;
|
||||
}
|
||||
return LocalComic(
|
||||
id: 0,
|
||||
id: '0',
|
||||
title: name,
|
||||
subtitle: '',
|
||||
tags: [],
|
||||
|
Reference in New Issue
Block a user