comic list & explore page

This commit is contained in:
nyne
2024-10-01 16:37:49 +08:00
parent fdb3901fd1
commit 16857185fc
15 changed files with 1232 additions and 102 deletions

711
lib/components/comic.dart Normal file
View 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(),
],
);
}
}

View File

@@ -2,6 +2,7 @@ library components;
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui'; import 'dart:ui';
@@ -13,9 +14,17 @@ import 'package:flutter/services.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/app_page_route.dart'; import 'package:venera/foundation/app_page_route.dart';
import 'package:venera/foundation/appdata.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/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/res.dart';
import 'package:venera/foundation/state_controller.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'; import 'package:venera/utils/translations.dart';
part 'image.dart'; part 'image.dart';
@@ -31,4 +40,5 @@ part 'navigation_bar.dart';
part 'pop_up_widget.dart'; part 'pop_up_widget.dart';
part 'scroll.dart'; part 'scroll.dart';
part 'select.dart'; part 'select.dart';
part 'side_bar.dart'; part 'side_bar.dart';
part 'comic.dart';

View File

@@ -15,18 +15,19 @@ class FlyoutController {
} }
class Flyout extends StatefulWidget { class Flyout extends StatefulWidget {
const Flyout( const Flyout({
{super.key, super.key,
required this.flyoutBuilder, required this.flyoutBuilder,
required this.child, required this.child,
this.enableTap = false, this.enableTap = false,
this.enableDoubleTap = false, this.enableDoubleTap = false,
this.enableLongPress = false, this.enableLongPress = false,
this.enableSecondaryTap = false, this.enableSecondaryTap = false,
this.withInkWell = false, this.withInkWell = false,
this.borderRadius = 0, this.borderRadius = 0,
this.controller, this.controller,
this.navigator}); this.navigator,
});
final WidgetBuilder flyoutBuilder; final WidgetBuilder flyoutBuilder;
@@ -164,7 +165,7 @@ class FlyoutContent extends StatelessWidget {
final String title; final String title;
final String? content; final Widget? content;
final List<Widget> actions; final List<Widget> actions;
@@ -191,7 +192,7 @@ class FlyoutContent extends StatelessWidget {
if (content != null) if (content != null)
Padding( Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Text(content!, style: const TextStyle(fontSize: 12)), child: content!,
), ),
const SizedBox( const SizedBox(
height: 12, height: 12,

View File

@@ -20,12 +20,25 @@ class NetworkError extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon( Center(
Icons.error_outline, child: Row(
size: 60, 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( const SizedBox(
height: 4, height: 8,
), ),
Text( Text(
message, message,
@@ -34,7 +47,7 @@ class NetworkError extends StatelessWidget {
), ),
if (retry != null) if (retry != null)
const SizedBox( const SizedBox(
height: 4, height: 12,
), ),
if (retry != null) if (retry != null)
FilledButton(onPressed: retry, child: Text('重试'.tl)) FilledButton(onPressed: retry, child: Text('重试'.tl))

View File

@@ -206,14 +206,6 @@ class _NaviPaneState extends State<NaviPane>
padding: const EdgeInsets.only(left: 16, right: 16), padding: const EdgeInsets.only(left: 16, right: 16),
height: _kTopBarHeight, height: _kTopBarHeight,
width: double.infinity, width: double.infinity,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 1,
),
),
),
child: Row( child: Row(
children: [ children: [
Text( Text(

View File

@@ -11,6 +11,13 @@ class _Appdata {
var file = File(FilePath.join(App.dataPath, 'settings.json')); var file = File(FilePath.join(App.dataPath, 'settings.json'));
await file.writeAsString(data); 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(); final appdata = _Appdata();
@@ -29,6 +36,9 @@ class _Settings {
'explore_pages': [], 'explore_pages': [],
'categories': [], 'categories': [],
'favorites': [], 'favorites': [],
'showFavoriteStatusOnTile': true,
'showHistoryStatusOnTile': false,
'blockedWords': [],
}; };
operator[](String key) { operator[](String key) {

View File

@@ -389,35 +389,39 @@ class Comic {
final String id; final String id;
final String? subTitle; final String? subtitle;
final List<String>? tags; final List<String>? tags;
final String description; final String description;
final String sourceKey; final String sourceKey;
final int? maxPage;
const Comic(this.title, this.cover, this.id, this.subTitle, this.tags, this.description, this.sourceKey); const Comic(this.title, this.cover, this.id, this.subtitle, this.tags, this.description, this.sourceKey, this.maxPage);
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
"title": title, "title": title,
"cover": cover, "cover": cover,
"id": id, "id": id,
"subTitle": subTitle, "subTitle": subtitle,
"tags": tags, "tags": tags,
"description": description, "description": description,
"sourceKey": sourceKey, "sourceKey": sourceKey,
"maxPage": maxPage,
}; };
} }
Comic.fromJson(Map<String, dynamic> json, this.sourceKey) Comic.fromJson(Map<String, dynamic> json, this.sourceKey)
: title = json["title"], : title = json["title"],
subTitle = json["subTitle"] ?? "", subtitle = json["subTitle"] ?? "",
cover = json["cover"], cover = json["cover"],
id = json["id"], id = json["id"],
tags = List<String>.from(json["tags"] ?? []), tags = List<String>.from(json["tags"] ?? []),
description = json["description"] ?? ""; description = json["description"] ?? "",
maxPage = json["maxPage"];
} }
class ComicDetails with HistoryMixin { class ComicDetails with HistoryMixin {

View File

@@ -455,11 +455,11 @@ class LocalFavoritesManager {
final _cachedFavoritedIds = <String, bool>{}; final _cachedFavoritedIds = <String, bool>{};
bool isExist(String id) { bool isExist(String id, ComicType type) {
if (_modifiedAfterLastCache) { if (_modifiedAfterLastCache) {
_cacheFavoritedIds(); _cacheFavoritedIds();
} }
return _cachedFavoritedIds.containsKey(id); return _cachedFavoritedIds.containsKey("$id@${type.value}");
} }
bool _modifiedAfterLastCache = true; bool _modifiedAfterLastCache = true;
@@ -468,11 +468,11 @@ class LocalFavoritesManager {
_modifiedAfterLastCache = false; _modifiedAfterLastCache = false;
_cachedFavoritedIds.clear(); _cachedFavoritedIds.clear();
for (var folder in folderNames) { for (var folder in folderNames) {
var res = _db.select(""" var rows = _db.select("""
select id from "$folder"; select id, type from "$folder";
"""); """);
for (var row in res) { for (var row in rows) {
_cachedFavoritedIds[row["id"]] = true; _cachedFavoritedIds["${row["id"]}@${row["type"]}"] = true;
} }
} }
} }

View File

@@ -1,12 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/widgets.dart' show ChangeNotifier; import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'app.dart'; import 'app.dart';
import 'log.dart';
typedef HistoryType = ComicType; typedef HistoryType = ComicType;
@@ -113,7 +111,7 @@ class History {
int ep = 0, int ep = 0,
int page = 0, int page = 0,
}) async { }) async {
var history = await HistoryManager().find(model.id); var history = await HistoryManager().find(model.id, model.historyType);
if (history != null) { if (history != null) {
return history; return history;
} }
@@ -147,36 +145,6 @@ class HistoryManager with ChangeNotifier {
Map<String, bool>? _cachedHistory; 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 { Future<void> init() async {
_db = sqlite3.open("${App.dataPath}/history.db"); _db = sqlite3.open("${App.dataPath}/history.db");
@@ -202,8 +170,8 @@ class HistoryManager with ChangeNotifier {
Future<void> addHistory(History newItem) async { Future<void> addHistory(History newItem) async {
var res = _db.select(""" var res = _db.select("""
select * from history select * from history
where id == ?; where id == ? and type == ?;
""", [newItem.id]); """, [newItem.id, newItem.type.value]);
if (res.isEmpty) { if (res.isEmpty) {
_db.execute(""" _db.execute("""
insert into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page) insert into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page)
@@ -224,8 +192,8 @@ class HistoryManager with ChangeNotifier {
_db.execute(""" _db.execute("""
update history update history
set time = ${DateTime.now().millisecondsSinceEpoch} set time = ${DateTime.now().millisecondsSinceEpoch}
where id == ?; where id == ? and type == ?;
""", [newItem.id]); """, [newItem.id, newItem.type.value]);
} }
updateCache(); updateCache();
notifyListeners(); notifyListeners();
@@ -235,13 +203,14 @@ class HistoryManager with ChangeNotifier {
_db.execute(""" _db.execute("""
update history update history
set time = ${DateTime.now().millisecondsSinceEpoch}, ep = ?, page = ?, readEpisode = ?, max_page = ? set time = ${DateTime.now().millisecondsSinceEpoch}, ep = ?, page = ?, readEpisode = ?, max_page = ?
where id == ?; where id == ? and type == ?;
""", [ """, [
history.ep, history.ep,
history.page, history.page,
history.readEpisode.join(','), history.readEpisode.join(','),
history.maxPage, history.maxPage,
history.id history.id,
history.type.value
]); ]);
notifyListeners(); notifyListeners();
} }
@@ -251,16 +220,16 @@ class HistoryManager with ChangeNotifier {
updateCache(); updateCache();
} }
void remove(String id) async { void remove(String id, ComicType type) async {
_db.execute(""" _db.execute("""
delete from history delete from history
where id == '$id'; where id == ? and type == ?;
"""); """, [id, type.value]);
updateCache(); updateCache();
} }
Future<History?> find(String id) async { Future<History?> find(String id, ComicType type) async {
return findSync(id); return findSync(id, type);
} }
void updateCache() { void updateCache() {
@@ -273,7 +242,7 @@ class HistoryManager with ChangeNotifier {
} }
} }
History? findSync(String id) { History? findSync(String id, ComicType type) {
if(_cachedHistory == null) { if(_cachedHistory == null) {
updateCache(); updateCache();
} }
@@ -283,8 +252,8 @@ class HistoryManager with ChangeNotifier {
var res = _db.select(""" var res = _db.select("""
select * from history select * from history
where id == ?; where id == ? and type == ?;
""", [id]); """, [id, type.value]);
if (res.isEmpty) { if (res.isEmpty) {
return null; return null;
} }

View File

@@ -4,17 +4,22 @@ import 'dart:io';
import 'package:flutter/widgets.dart' show ChangeNotifier; import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'app.dart'; import 'app.dart';
class LocalComic { class LocalComic implements Comic{
final int id; @override
final String id;
@override
final String title; final String title;
@override
final String subtitle; final String subtitle;
@override
final List<String> tags; final List<String> tags;
/// name of the directory, which is in `LocalManager.path` /// name of the directory, which is in `LocalManager.path`
@@ -26,6 +31,7 @@ class LocalComic {
final Map<String, String>? chapters; final Map<String, String>? chapters;
/// relative path to the cover image /// relative path to the cover image
@override
final String cover; final String cover;
final ComicType comicType; final ComicType comicType;
@@ -45,7 +51,7 @@ class LocalComic {
}); });
LocalComic.fromRow(Row row) LocalComic.fromRow(Row row)
: id = row[0] as int, : id = row[0] as String,
title = row[1] as String, title = row[1] as String,
subtitle = row[2] as String, subtitle = row[2] as String,
tags = List.from(jsonDecode(row[3] as String)), tags = List.from(jsonDecode(row[3] as String)),
@@ -56,6 +62,28 @@ class LocalComic {
createdAt = DateTime.fromMillisecondsSinceEpoch(row[8] as int); createdAt = DateTime.fromMillisecondsSinceEpoch(row[8] as int);
File get coverFile => File('${LocalManager().path}/$directory/$cover'); 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 { class LocalManager with ChangeNotifier {
@@ -77,7 +105,7 @@ class LocalManager with ChangeNotifier {
); );
_db.execute(''' _db.execute('''
CREATE TABLE IF NOT EXISTS comics ( CREATE TABLE IF NOT EXISTS comics (
id INTEGER, id TEXT NOT NULL,
title TEXT NOT NULL, title TEXT NOT NULL,
subtitle TEXT NOT NULL, subtitle TEXT NOT NULL,
tags TEXT NOT NULL, tags TEXT NOT NULL,
@@ -108,18 +136,21 @@ class LocalManager with ChangeNotifier {
} }
} }
int findValidId(ComicType type) { String findValidId(ComicType type) {
final res = _db.select( final res = _db.select('''
'SELECT id FROM comics WHERE comic_type = ? ORDER BY id DESC LIMIT 1;', SELECT id FROM comics WHERE comic_type = ?
ORDER BY CAST(id AS INTEGER) DESC
LIMIT 1;
'''
[type.value], [type.value],
); );
if (res.isEmpty) { 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( _db.execute(
'INSERT INTO comics VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);', 'INSERT INTO comics VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);',
[ [
@@ -137,7 +168,7 @@ class LocalManager with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void remove(int id, ComicType comicType) async { void remove(String id, ComicType comicType) async {
_db.execute( _db.execute(
'DELETE FROM comics WHERE id = ? AND comic_type = ?;', 'DELETE FROM comics WHERE id = ? AND comic_type = ?;',
[id, comicType.value], [id, comicType.value],
@@ -155,7 +186,7 @@ class LocalManager with ChangeNotifier {
return res.map((row) => LocalComic.fromRow(row)).toList(); return res.map((row) => LocalComic.fromRow(row)).toList();
} }
LocalComic? find(int id, ComicType comicType) { LocalComic? find(String id, ComicType comicType) {
final res = _db.select( final res = _db.select(
'SELECT * FROM comics WHERE id = ? AND comic_type = ?;', 'SELECT * FROM comics WHERE id = ? AND comic_type = ?;',
[id, comicType.value], [id, comicType.value],

View File

@@ -64,6 +64,10 @@ extension WidgetExtension on Widget{
Widget fixHeight(double height){ Widget fixHeight(double height){
return SizedBox(height: height, child: this); return SizedBox(height: height, child: this);
} }
Widget toSliver(){
return SliverToBoxAdapter(child: this);
}
} }
/// create default text style /// create default text style

View File

@@ -8,9 +8,12 @@ import 'package:venera/foundation/local.dart';
import 'package:venera/network/cookie_jar.dart'; import 'package:venera/network/cookie_jar.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'foundation/appdata.dart';
Future<void> init() async { Future<void> init() async {
await AppTranslation.init(); await AppTranslation.init();
await App.init(); await App.init();
await appdata.init();
await HistoryManager().init(); await HistoryManager().init();
await LocalManager().init(); await LocalManager().init();
await LocalFavoritesManager().init(); await LocalFavoritesManager().init();

View File

@@ -155,7 +155,7 @@ class _BodyState extends State<_Body> {
() { () {
var file = File(source.filePath); var file = File(source.filePath);
file.delete(); file.delete();
ComicSource.all().remove(source); ComicSource.remove(source.key);
_validatePages(); _validatePages();
App.forceRebuild(); App.forceRebuild();
}, },

View File

@@ -1,10 +1,392 @@
import 'package:flutter/material.dart'; 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}); 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 @override
Widget build(BuildContext context) { 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);
}

View File

@@ -539,7 +539,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
return null; return null;
} }
return LocalComic( return LocalComic(
id: 0, id: '0',
title: name, title: name,
subtitle: '', subtitle: '',
tags: [], tags: [],