diff --git a/lib/components/comic.dart b/lib/components/comic.dart new file mode 100644 index 0000000..44170f2 --- /dev/null +++ b/lib/components/comic.dart @@ -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? tags; + final int maxLines; + + @override + Widget build(BuildContext context) { + if (tags != null) { + tags!.removeWhere((element) => element.removeAllBlank == ""); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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 comics; + + final void Function()? onLastItemBuild; + + @override + Widget build(BuildContext context) { + return StateBuilder( + init: SliverGridComicsController(), + builder: (controller) { + List 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 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 ?? []) { + 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>> Function(int page)? loadPage; + + final Future>> Function(String? next)? loadNext; + + @override + State createState() => _ComicListState(); +} + +class _ComicListState extends State { + int? maxPage; + + Map> data = {}; + + int page = 1; + + String? error; + + Map 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: [ + 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 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 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(), + ], + ); + } +} diff --git a/lib/components/components.dart b/lib/components/components.dart index 690e54a..b7ae14b 100644 --- a/lib/components/components.dart +++ b/lib/components/components.dart @@ -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'; @@ -31,4 +40,5 @@ part 'navigation_bar.dart'; part 'pop_up_widget.dart'; part 'scroll.dart'; part 'select.dart'; -part 'side_bar.dart'; \ No newline at end of file +part 'side_bar.dart'; +part 'comic.dart'; \ No newline at end of file diff --git a/lib/components/flyout.dart b/lib/components/flyout.dart index 596f1dc..1d1cd4c 100644 --- a/lib/components/flyout.dart +++ b/lib/components/flyout.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 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, diff --git a/lib/components/loading.dart b/lib/components/loading.dart index 9900528..361d746 100644 --- a/lib/components/loading.dart +++ b/lib/components/loading.dart @@ -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)) diff --git a/lib/components/navigation_bar.dart b/lib/components/navigation_bar.dart index b578ef2..b75c6b0 100644 --- a/lib/components/navigation_bar.dart +++ b/lib/components/navigation_bar.dart @@ -206,14 +206,6 @@ class _NaviPaneState extends State 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( diff --git a/lib/foundation/appdata.dart b/lib/foundation/appdata.dart index 7b124a5..866d27f 100644 --- a/lib/foundation/appdata.dart +++ b/lib/foundation/appdata.dart @@ -11,6 +11,13 @@ class _Appdata { var file = File(FilePath.join(App.dataPath, 'settings.json')); await file.writeAsString(data); } + + Future init() async { + var json = jsonDecode(await File(FilePath.join(App.dataPath, 'settings.json')).readAsString()) as Map; + 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) { diff --git a/lib/foundation/comic_source/comic_source.dart b/lib/foundation/comic_source/comic_source.dart index 2419e8f..f24ec69 100644 --- a/lib/foundation/comic_source/comic_source.dart +++ b/lib/foundation/comic_source/comic_source.dart @@ -389,35 +389,39 @@ class Comic { final String id; - final String? subTitle; + final String? subtitle; final List? tags; final String description; 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 toJson() { return { "title": title, "cover": cover, "id": id, - "subTitle": subTitle, + "subTitle": subtitle, "tags": tags, "description": description, "sourceKey": sourceKey, + "maxPage": maxPage, }; } Comic.fromJson(Map json, this.sourceKey) : title = json["title"], - subTitle = json["subTitle"] ?? "", + subtitle = json["subTitle"] ?? "", cover = json["cover"], id = json["id"], tags = List.from(json["tags"] ?? []), - description = json["description"] ?? ""; + description = json["description"] ?? "", + maxPage = json["maxPage"]; } class ComicDetails with HistoryMixin { diff --git a/lib/foundation/favorites.dart b/lib/foundation/favorites.dart index a8f0a16..892f592 100644 --- a/lib/foundation/favorites.dart +++ b/lib/foundation/favorites.dart @@ -455,11 +455,11 @@ class LocalFavoritesManager { final _cachedFavoritedIds = {}; - 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; } } } diff --git a/lib/foundation/history.dart b/lib/foundation/history.dart index b0af63c..5dc5a46 100644 --- a/lib/foundation/history.dart +++ b/lib/foundation/history.dart @@ -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? _cachedHistory; - Future 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 init() async { _db = sqlite3.open("${App.dataPath}/history.db"); @@ -202,8 +170,8 @@ class HistoryManager with ChangeNotifier { Future 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 find(String id) async { - return findSync(id); + Future 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; } diff --git a/lib/foundation/local.dart b/lib/foundation/local.dart index 52c1f9c..1b4592e 100644 --- a/lib/foundation/local.dart +++ b/lib/foundation/local.dart @@ -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 tags; /// name of the directory, which is in `LocalManager.path` @@ -26,6 +31,7 @@ class LocalComic { final Map? 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 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 add(LocalComic comic, [int? id]) async { + Future 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], diff --git a/lib/foundation/widget_utils.dart b/lib/foundation/widget_utils.dart index aff8be4..902e2cd 100644 --- a/lib/foundation/widget_utils.dart +++ b/lib/foundation/widget_utils.dart @@ -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 diff --git a/lib/init.dart b/lib/init.dart index 2106699..040b8e6 100644 --- a/lib/init.dart +++ b/lib/init.dart @@ -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 init() async { await AppTranslation.init(); await App.init(); + await appdata.init(); await HistoryManager().init(); await LocalManager().init(); await LocalFavoritesManager().init(); diff --git a/lib/pages/comic_source_page.dart b/lib/pages/comic_source_page.dart index f3b51a8..ca13f1e 100644 --- a/lib/pages/comic_source_page.dart +++ b/lib/pages/comic_source_page.dart @@ -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(); }, diff --git a/lib/pages/explore_page.dart b/lib/pages/explore_page.dart index 9d541e4..3618e98 100644 --- a/lib/pages/explore_page.dart +++ b/lib/pages/explore_page.dart @@ -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 createState() => _ExplorePageState(); +} + +class _ExplorePageState extends State + with TickerProviderStateMixin { + late TabController controller; + + bool showFB = true; + + double location = 0; + + late List pages; + + @override + void initState() { + pages = List.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(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( + 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( + 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? 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 _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 buildSlivers(BuildContext context, List data) sync* { + List 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); + } + } + if (cache.isNotEmpty) { + yield SliverGridComics( + comics: (cache), + ); + } + } + + @override + Widget buildContent(BuildContext context, List data) { + return SmoothCustomScrollView( + slivers: [ + ...buildSlivers(context, data), + if (haveNextPage) const ListLoadingIndicator().toSliver() + ], + ); + } + + @override + Future>> 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) { + return const Res.error("function loadMixed return invalid data"); + } + } + return res; + } +} + +Iterable _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); +} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 8514b00..15444fa 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -539,7 +539,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { return null; } return LocalComic( - id: 0, + id: '0', title: name, subtitle: '', tags: [],