diff --git a/.gitignore b/.gitignore index 741387463..8e3a9e68c 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ android/build/reports/problems/ **/macos/Pods/ **/ios/Podfile.lock **/macos/Podfile.lock + +# Local documentation +changelog.md diff --git a/android/app/build/v1.2.6/BIT2137.tmp b/android/app/build/v1.2.6/BIT2137.tmp new file mode 100644 index 000000000..101140519 Binary files /dev/null and b/android/app/build/v1.2.6/BIT2137.tmp differ diff --git a/lib/bean/card/bangumi_card.dart b/lib/bean/card/bangumi_card.dart index dabe803a6..b264dad4a 100644 --- a/lib/bean/card/bangumi_card.dart +++ b/lib/bean/card/bangumi_card.dart @@ -12,11 +12,15 @@ class BangumiCardV extends StatelessWidget { required this.bangumiItem, this.canTap = true, this.enableHero = true, + this.onLongPress, + this.onSecondaryTap, }); final BangumiItem bangumiItem; final bool canTap; final bool enableHero; + final VoidCallback? onLongPress; + final VoidCallback? onSecondaryTap; @override Widget build(BuildContext context) { @@ -25,6 +29,8 @@ class BangumiCardV extends StatelessWidget { clipBehavior: Clip.antiAlias, margin: EdgeInsets.zero, child: GestureDetector( + onLongPress: onLongPress, + onSecondaryTap: onSecondaryTap, child: InkWell( onTap: () { if (!canTap) { diff --git a/lib/bean/card/bangumi_timeline_card.dart b/lib/bean/card/bangumi_timeline_card.dart index e66cedee6..fea927116 100644 --- a/lib/bean/card/bangumi_timeline_card.dart +++ b/lib/bean/card/bangumi_timeline_card.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:kazumi/modules/bangumi/bangumi_item.dart'; -import 'package:kazumi/bean/card/network_img_layer.dart'; import 'package:kazumi/utils/device.dart'; +import 'package:kazumi/bean/card/network_img_layer.dart'; /// 时间线番剧卡片 class BangumiTimelineCard extends StatelessWidget { @@ -11,22 +11,28 @@ class BangumiTimelineCard extends StatelessWidget { required this.bangumiItem, required this.showRating, this.onTap, + this.onLongPress, + this.onSecondaryTap, this.cardHeight = 120, this.cardWidth, this.enableHero = true, + this.episodeCount, }); final BangumiItem bangumiItem; final bool showRating; final VoidCallback? onTap; + final VoidCallback? onLongPress; + final VoidCallback? onSecondaryTap; final bool enableHero; final double cardHeight; final double? cardWidth; + final int? episodeCount; @override Widget build(BuildContext context) { - final desktopLayout = isDesktop(); - final tabletLayout = isTablet(); + final desktop = isDesktop(); + final tablet = isTablet(); final theme = Theme.of(context); final textScaler = MediaQuery.textScalerOf(context); final colorScheme = theme.colorScheme; @@ -46,9 +52,12 @@ class BangumiTimelineCard extends StatelessWidget { ), clipBehavior: Clip.antiAlias, color: colorScheme.surfaceContainerLow, - child: InkWell( - borderRadius: BorderRadius.circular(borderRadius), - onTap: onTap ?? + child: GestureDetector( + onLongPress: onLongPress, + onSecondaryTap: onSecondaryTap, + child: InkWell( + borderRadius: BorderRadius.circular(borderRadius), + onTap: onTap ?? () { Modular.to.pushNamed('/info/', arguments: bangumiItem); }, @@ -71,14 +80,14 @@ class BangumiTimelineCard extends StatelessWidget { ), const SizedBox(width: 12), Expanded( - child: buildInfo( - context, textScaler, desktopLayout, tabletLayout), + child: buildInfo(context, textScaler, desktop, tablet), ), ], ), ), ), ), + ), ); } @@ -172,10 +181,20 @@ class BangumiTimelineCard extends StatelessWidget { final rankText = showRating ? '#${bangumiItem.rank}' : '#***'; final votesText = showRating ? bangumiItem.votes.toString() : '***'; + final hasEpisodeCount = episodeCount != null && episodeCount! > 0; + return Wrap( spacing: 8, runSpacing: 4, children: [ + if (hasEpisodeCount) + buildMetric( + context, + icon: Icons.play_circle_outline_rounded, + iconColor: colorScheme.tertiary, + label: '更新至第$episodeCount话', + textStyle: metricStyle, + ), if (showScore) buildMetric( context, diff --git a/lib/bean/widget/collect_button.dart b/lib/bean/widget/collect_button.dart index e6a13cca3..b2e432ead 100644 --- a/lib/bean/widget/collect_button.dart +++ b/lib/bean/widget/collect_button.dart @@ -10,6 +10,7 @@ class CollectButton extends StatefulWidget { this.color = Colors.white, this.onOpen, this.onClose, + this.menuController, }) { isExtended = false; } @@ -20,6 +21,7 @@ class CollectButton extends StatefulWidget { this.color = Colors.white, this.onOpen, this.onClose, + this.menuController, }) { isExtended = true; } @@ -29,6 +31,7 @@ class CollectButton extends StatefulWidget { late final bool isExtended; final void Function()? onOpen; final void Function()? onClose; + final MenuController? menuController; @override State createState() => _CollectButtonState(); @@ -43,49 +46,42 @@ class _CollectButtonState extends State { late int collectType; final CollectController collectController = Modular.get(); + static const Map _typeLabels = { + 0: '未追', + 1: '在看', + 2: '想看', + 3: '搁置', + 4: '看过', + 5: '抛弃', + }; + + static const Map _typeIcons = { + 0: Icons.favorite_border, + 1: Icons.favorite, + 2: Icons.star_rounded, + 3: Icons.pending_actions, + 4: Icons.done, + 5: Icons.heart_broken, + }; + @override void initState() { super.initState(); } String getTypeStringByInt(int collectType) { - switch (collectType) { - case 1: - return "在看"; - case 2: - return "想看"; - case 3: - return "搁置"; - case 4: - return "看过"; - case 5: - return "抛弃"; - default: - return "未追"; - } + return _typeLabels[collectType] ?? '未追'; } IconData getIconByInt(int collectType) { - switch (collectType) { - case 1: - return Icons.favorite; - case 2: - return Icons.star_rounded; - case 3: - return Icons.pending_actions; - case 4: - return Icons.done; - case 5: - return Icons.heart_broken; - default: - return Icons.favorite_border; - } + return _typeIcons[collectType] ?? Icons.favorite_border; } @override Widget build(BuildContext context) { collectType = collectController.getCollectType(widget.bangumiItem); return MenuAnchor( + controller: widget.menuController, consumeOutsideTap: true, onClose: widget.onClose, onOpen: widget.onOpen, @@ -117,6 +113,9 @@ class _CollectButtonState extends State { getIconByInt(collectType), color: widget.color, ), + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + iconSize: 18, + padding: EdgeInsets.zero, ); } }, @@ -125,8 +124,7 @@ class _CollectButtonState extends State { (int index) => MenuItemButton( onPressed: () async { if (index != collectType && mounted) { - await collectController.addCollect(widget.bangumiItem, - type: index); + await collectController.addCollect(widget.bangumiItem, type: index); // 防止状态错误刷新 if (!mounted) { return; diff --git a/lib/bean/widget/collectable_card_wrapper.dart b/lib/bean/widget/collectable_card_wrapper.dart new file mode 100644 index 000000000..f00dd4f6a --- /dev/null +++ b/lib/bean/widget/collectable_card_wrapper.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:kazumi/modules/bangumi/bangumi_item.dart'; +import 'package:kazumi/bean/widget/collect_button.dart'; + +class CollectableCardWrapper extends StatefulWidget { + const CollectableCardWrapper({ + super.key, + required this.bangumiItem, + required this.child, + }); + + final BangumiItem bangumiItem; + final Widget child; + + @override + State createState() => _CollectableCardWrapperState(); +} + +class _CollectableCardWrapperState extends State { + final MenuController menuController = MenuController(); + + void _openMenu() { + if (!menuController.isOpen) { + menuController.open(); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Stack( + children: [ + GestureDetector( + onLongPress: _openMenu, + onSecondaryTap: _openMenu, + child: widget.child, + ), + Positioned( + right: 4, + bottom: 8, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh.withAlpha(180), + shape: BoxShape.circle, + ), + child: CollectButton( + bangumiItem: widget.bangumiItem, + color: colorScheme.onSurface.withAlpha(180), + menuController: menuController, + ), + ), + ), + ], + ); + } +} diff --git a/lib/modules/bangumi/bangumi_item.dart b/lib/modules/bangumi/bangumi_item.dart index 53aa3f67f..79c608a92 100644 --- a/lib/modules/bangumi/bangumi_item.dart +++ b/lib/modules/bangumi/bangumi_item.dart @@ -37,6 +37,8 @@ class BangumiItem { List votesCount; @HiveField(14, defaultValue: '') String info; + @HiveField(15, defaultValue: '') + String airTime; BangumiInterest? interest; BangumiItem({ @@ -55,6 +57,7 @@ class BangumiItem { required this.votes, required this.votesCount, required this.info, + this.airTime = '', this.interest, }); @@ -122,7 +125,17 @@ class BangumiItem { return ''; } + String resolveAirTimeString(Map jsonData) { + final airtime = jsonData['airtime']; + if (airtime is Map && airtime['time'] != null) { + final s = airtime['time'].toString().trim(); + return s.isNotEmpty ? s : ''; + } + return ''; + } + final String airDateStr = resolveAirDateString(json); + final String airTimeStr = resolveAirTimeString(json); List list = json['tags'] ?? []; List bangumiAlias = parseBangumiAliases(json); @@ -163,6 +176,7 @@ class BangumiItem { votes: json['rating']['total'] ?? 0, votesCount: voteList, info: json['info'] ?? '', + airTime: airTimeStr, interest: interest, ); } diff --git a/lib/modules/bangumi/bangumi_item.g.dart b/lib/modules/bangumi/bangumi_item.g.dart index 8cbfcc083..cd3a4e611 100644 --- a/lib/modules/bangumi/bangumi_item.g.dart +++ b/lib/modules/bangumi/bangumi_item.g.dart @@ -32,13 +32,14 @@ class BangumiItemAdapter extends TypeAdapter { votes: fields[12] == null ? 0 : (fields[12] as num).toInt(), votesCount: fields[13] == null ? [] : (fields[13] as List).cast(), info: fields[14] == null ? '' : fields[14] as String, + airTime: fields[15] == null ? '' : fields[15] as String, ); } @override void write(BinaryWriter writer, BangumiItem obj) { writer - ..writeByte(15) + ..writeByte(16) ..writeByte(0) ..write(obj.id) ..writeByte(1) @@ -68,7 +69,9 @@ class BangumiItemAdapter extends TypeAdapter { ..writeByte(13) ..write(obj.votesCount) ..writeByte(14) - ..write(obj.info); + ..write(obj.info) + ..writeByte(15) + ..write(obj.airTime); } @override diff --git a/lib/modules/bangumi/episode_item.dart b/lib/modules/bangumi/episode_item.dart index 5d9a37673..1ab4c8031 100644 --- a/lib/modules/bangumi/episode_item.dart +++ b/lib/modules/bangumi/episode_item.dart @@ -4,6 +4,8 @@ class EpisodeInfo { int type; String name; String nameCn; + String airdate; + String status; EpisodeInfo({ required this.id, @@ -11,15 +13,31 @@ class EpisodeInfo { required this.type, required this.name, required this.nameCn, + this.airdate = '', + this.status = '', }); + bool get isAired { + if (status == 'Air') return true; + if (airdate.isEmpty) return false; + try { + return DateTime.parse(airdate).isBefore(DateTime.now()) || + DateTime.parse(airdate).isAtSameMomentAs( + DateTime(DateTime.now().year, DateTime.now().month, DateTime.now().day)); + } catch (_) { + return false; + } + } + factory EpisodeInfo.fromJson(Map json) { return EpisodeInfo( id: json['id'] ?? 0, episode: json['sort'] ?? 0, type: json['type'] ?? 0, name: json['name'] ?? '', - nameCn: json['name_cn'] ?? ''); + nameCn: json['name_cn'] ?? '', + airdate: (json['airdate'] as String?) ?? '', + status: (json['status'] as String?) ?? ''); } factory EpisodeInfo.fromTemplate() { @@ -32,6 +50,8 @@ class EpisodeInfo { type = 0; name = ''; nameCn = ''; + airdate = ''; + status = ''; } String readType() { diff --git a/lib/modules/collect/collect_module.dart b/lib/modules/collect/collect_module.dart index 74dccb538..96624a515 100644 --- a/lib/modules/collect/collect_module.dart +++ b/lib/modules/collect/collect_module.dart @@ -21,9 +21,13 @@ class CollectedBangumi { @HiveField(2) int type; + /// Total episode count from Bangumi API, 0 = unknown + @HiveField(3) + int eps; + String get key => bangumiItem.id.toString(); - CollectedBangumi(this.bangumiItem, this.time, this.type); + CollectedBangumi(this.bangumiItem, this.time, this.type, {this.eps = 0}); static String getKey(BangumiItem bangumiItem) => bangumiItem.id.toString(); diff --git a/lib/modules/collect/collect_module.g.dart b/lib/modules/collect/collect_module.g.dart index c53a4f5e7..00d3043ad 100644 --- a/lib/modules/collect/collect_module.g.dart +++ b/lib/modules/collect/collect_module.g.dart @@ -20,19 +20,22 @@ class CollectedBangumiAdapter extends TypeAdapter { fields[0] as BangumiItem, fields[1] as DateTime, (fields[2] as num).toInt(), + eps: fields[3] == null ? 0 : (fields[3] as num).toInt(), ); } @override void write(BinaryWriter writer, CollectedBangumi obj) { writer - ..writeByte(3) + ..writeByte(4) ..writeByte(0) ..write(obj.bangumiItem) ..writeByte(1) ..write(obj.time) ..writeByte(2) - ..write(obj.type); + ..write(obj.type) + ..writeByte(3) + ..write(obj.eps); } @override diff --git a/lib/pages/collect/collect_controller.dart b/lib/pages/collect/collect_controller.dart index c6a16e355..a4e78333b 100644 --- a/lib/pages/collect/collect_controller.dart +++ b/lib/pages/collect/collect_controller.dart @@ -8,6 +8,7 @@ import 'package:kazumi/modules/collect/collect_type.dart'; import 'package:kazumi/services/sync/bangumi_sync_service.dart'; import 'package:kazumi/services/storage/storage.dart'; import 'package:kazumi/services/sync/webdav.dart'; +import 'package:kazumi/utils/update_check_service.dart'; import 'package:kazumi/repositories/collect_crud_repository.dart'; import 'package:kazumi/repositories/collect_repository.dart'; import 'package:hive_ce/hive.dart'; @@ -37,17 +38,32 @@ abstract class _CollectController with Store { ObservableList collectibles = ObservableList(); + @observable + ObservableSet bangumiIdsWithUpdate = ObservableSet(); + + final UpdateCheckService _updateCheckService = UpdateCheckService(); + void loadCollectibles() { collectibles.clear(); collectibles.addAll(_collectCrudRepository.getAllCollectibles()); + Future.delayed(Duration.zero, () => checkForUpdates()); } - int getCollectType(BangumiItem bangumiItem) { - return _collectCrudRepository.getCollectType(bangumiItem.id); + @action + Future checkForUpdates() async { + try { + final ids = await _updateCheckService + .checkUpdates(collectibles.toList()) + .timeout(const Duration(seconds: 20)); + bangumiIdsWithUpdate.clear(); + bangumiIdsWithUpdate.addAll(ids); + } catch (e) { + KazumiLogger().w('UpdateCheck: failed ($e)'); + } } - BangumiItem? getCollectibleBangumiItem(int id) { - return _collectCrudRepository.getCollectible(id)?.bangumiItem; + int getCollectType(BangumiItem bangumiItem) { + return _collectCrudRepository.getCollectType(bangumiItem.id); } @action diff --git a/lib/pages/collect/collect_controller.g.dart b/lib/pages/collect/collect_controller.g.dart index 4ed5a1c48..58ef93dd8 100644 --- a/lib/pages/collect/collect_controller.g.dart +++ b/lib/pages/collect/collect_controller.g.dart @@ -25,6 +25,31 @@ mixin _$CollectController on _CollectController, Store { }); } + late final _$bangumiIdsWithUpdateAtom = + Atom(name: '_CollectController.bangumiIdsWithUpdate', context: context); + + @override + ObservableSet get bangumiIdsWithUpdate { + _$bangumiIdsWithUpdateAtom.reportRead(); + return super.bangumiIdsWithUpdate; + } + + @override + set bangumiIdsWithUpdate(ObservableSet value) { + _$bangumiIdsWithUpdateAtom.reportWrite(value, super.bangumiIdsWithUpdate, + () { + super.bangumiIdsWithUpdate = value; + }); + } + + late final _$checkForUpdatesAsyncAction = + AsyncAction('_CollectController.checkForUpdates', context: context); + + @override + Future checkForUpdates() { + return _$checkForUpdatesAsyncAction.run(() => super.checkForUpdates()); + } + late final _$addCollectAsyncAction = AsyncAction('_CollectController.addCollect', context: context); @@ -46,7 +71,8 @@ mixin _$CollectController on _CollectController, Store { @override String toString() { return ''' -collectibles: ${collectibles} +collectibles: ${collectibles}, +bangumiIdsWithUpdate: ${bangumiIdsWithUpdate} '''; } } diff --git a/lib/pages/collect/collect_page.dart b/lib/pages/collect/collect_page.dart index 2238d3445..b446ef69c 100644 --- a/lib/pages/collect/collect_page.dart +++ b/lib/pages/collect/collect_page.dart @@ -13,6 +13,7 @@ import 'package:kazumi/bean/appbar/sys_app_bar.dart'; import 'package:provider/provider.dart'; import 'package:kazumi/bean/widget/collect_button.dart'; import 'package:hive_ce/hive.dart'; +import 'package:kazumi/modules/bangumi/bangumi_item.dart'; import 'package:kazumi/modules/collect/collect_sync_plan.dart'; import 'package:kazumi/services/storage/storage.dart'; @@ -305,41 +306,16 @@ class _CollectPageState extends State ), delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { - return collectedBangumiRenderItem.isNotEmpty - ? Stack( - children: [ - BangumiCardV( - bangumiItem: collectedBangumiRenderItem[index] - .bangumiItem, - canTap: !showDelete, - ), - Positioned( - right: 5, - bottom: 5, - child: showDelete - ? Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .secondaryContainer, - shape: BoxShape.circle, - ), - child: CollectButton( - bangumiItem: - collectedBangumiRenderItem[index] - .bangumiItem, - color: Theme.of(context) - .colorScheme - .onSecondaryContainer, - ), - ) - : Container(), - ), - ], - ) - : null; + if (collectedBangumiRenderItem.isEmpty) return null; + final item = + collectedBangumiRenderItem[index].bangumiItem; + return _CollectableBangumiCard( + bangumiItem: item, + showDelete: showDelete, + hasUpdate: + collectController.bangumiIdsWithUpdate + .contains(item.id), + ); }, childCount: collectedBangumiRenderItem.isNotEmpty ? collectedBangumiRenderItem.length @@ -355,6 +331,93 @@ class _CollectPageState extends State } } +class _CollectableBangumiCard extends StatefulWidget { + const _CollectableBangumiCard({ + required this.bangumiItem, + required this.showDelete, + required this.hasUpdate, + }); + + final BangumiItem bangumiItem; + final bool showDelete; + final bool hasUpdate; + + @override + State<_CollectableBangumiCard> createState() => + _CollectableBangumiCardState(); +} + +class _CollectableBangumiCardState extends State<_CollectableBangumiCard> { + final MenuController menuController = MenuController(); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Stack( + children: [ + BangumiCardV( + bangumiItem: widget.bangumiItem, + canTap: !widget.showDelete, + onLongPress: () { + if (!menuController.isOpen) { + menuController.open(); + } + }, + onSecondaryTap: () { + if (!menuController.isOpen) { + menuController.open(); + } + }, + ), + if (widget.hasUpdate) + Positioned( + top: 4, + right: 4, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: colorScheme.error, + shape: BoxShape.circle, + ), + ), + ), + Positioned( + right: 5, + bottom: 5, + child: widget.showDelete + ? Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + shape: BoxShape.circle, + ), + child: CollectButton( + bangumiItem: widget.bangumiItem, + color: colorScheme.onSecondaryContainer, + menuController: menuController, + ), + ) + : Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh.withAlpha(180), + shape: BoxShape.circle, + ), + child: CollectButton( + bangumiItem: widget.bangumiItem, + color: colorScheme.onSurface.withAlpha(180), + menuController: menuController, + ), + ), + ), + ], + ); + } +} + class _FullSyncProgressDialog extends StatefulWidget { const _FullSyncProgressDialog({super.key}); diff --git a/lib/pages/popular/popular_page.dart b/lib/pages/popular/popular_page.dart index 45b4ad28c..31fca83f9 100644 --- a/lib/pages/popular/popular_page.dart +++ b/lib/pages/popular/popular_page.dart @@ -15,6 +15,7 @@ import 'package:kazumi/pages/menu/menu.dart'; import 'package:kazumi/services/storage/storage.dart'; import 'package:kazumi/bean/appbar/drag_to_move_bar.dart' as dtb; import 'package:kazumi/utils/device.dart'; +import 'package:kazumi/bean/widget/collectable_card_wrapper.dart'; class PopularPage extends StatefulWidget { const PopularPage({super.key}); @@ -189,7 +190,10 @@ class _PopularPageState extends State delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { return bangumiList!.isNotEmpty - ? BangumiCardV(bangumiItem: bangumiList[index]) + ? CollectableCardWrapper( + bangumiItem: bangumiList[index], + child: BangumiCardV(bangumiItem: bangumiList[index]), + ) : null; }, childCount: bangumiList!.isNotEmpty ? bangumiList!.length : 10, diff --git a/lib/pages/search/search_page.dart b/lib/pages/search/search_page.dart index 40450901e..2289c6270 100644 --- a/lib/pages/search/search_page.dart +++ b/lib/pages/search/search_page.dart @@ -8,6 +8,7 @@ import 'package:kazumi/bean/appbar/sys_app_bar.dart'; import 'package:kazumi/modules/bangumi/bangumi_item.dart'; import 'package:kazumi/services/logging/logger.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:kazumi/bean/widget/collectable_card_wrapper.dart'; class SearchPage extends StatefulWidget { const SearchPage({super.key, this.inputTag = ''}); @@ -356,9 +357,12 @@ class _SearchPageState extends State { itemCount: filteredList.isNotEmpty ? filteredList.length : 10, itemBuilder: (context, index) { return filteredList.isNotEmpty - ? BangumiCardV( - enableHero: false, + ? CollectableCardWrapper( bangumiItem: filteredList[index], + child: BangumiCardV( + enableHero: false, + bangumiItem: filteredList[index], + ), ) : Container(); }, diff --git a/lib/pages/timeline/timeline_controller.dart b/lib/pages/timeline/timeline_controller.dart index c67668357..c048e80c4 100644 --- a/lib/pages/timeline/timeline_controller.dart +++ b/lib/pages/timeline/timeline_controller.dart @@ -27,6 +27,12 @@ abstract class _TimelineController with Store { @observable bool isTimeOut = false; + @observable + ObservableMap episodeCounts = ObservableMap(); + + @observable + bool isLoadingEpisodes = false; + @observable late bool notShowAbandonedBangumis = _collectRepository.getTimelineNotShowAbandonedBangumis(); @@ -62,6 +68,45 @@ abstract class _TimelineController with Store { changeSortType(sortType); isLoading = false; isTimeOut = bangumiCalendar.isEmpty; + fetchEpisodeCounts(); + } + + @action + Future fetchEpisodeCounts() async { + isLoadingEpisodes = true; + final ids = {}; + for (final dayList in bangumiCalendar) { + for (final item in dayList) { + if (!episodeCounts.containsKey(item.id)) { + ids.add(item.id); + } + } + } + + // 并行加载,每10个一组以避免过多并发请求 + const batchSize = 10; + final idList = ids.toList(); + + for (var i = 0; i < idList.length; i += batchSize) { + final end = (i + batchSize < idList.length) ? i + batchSize : idList.length; + final batch = idList.sublist(i, end); + + await Future.wait(batch.map((id) async { + try { + final episodes = await BangumiApi.getBangumiEpisodesByID(id); + final airedEpisodes = episodes.where((e) => e.type == 0 && e.isAired); + if (airedEpisodes.isNotEmpty) { + final latest = airedEpisodes.map((e) => e.episode).reduce( + (a, b) => a > b ? a : b); + episodeCounts[id] = latest.toInt(); + } + } catch (_) { + // skip failed fetches + } + })); + } + + isLoadingEpisodes = false; } Future getSchedulesBySeason() async { @@ -79,6 +124,9 @@ abstract class _TimelineController with Store { if (!isTimeOut) { changeSortType(sortType); } + if (!isTimeOut) { + fetchEpisodeCounts(); + } return; } @@ -109,6 +157,9 @@ abstract class _TimelineController with Store { if (!isTimeOut) { changeSortType(sortType); } + if (!isTimeOut) { + fetchEpisodeCounts(); + } } void tryEnterSeason(DateTime date) { @@ -120,8 +171,9 @@ abstract class _TimelineController with Store { /// 1. default /// 2. score /// 3. heat + /// 4. air date void changeSortType(int type) { - if (type < 1 || type > 3) { + if (type < 1 || type > 4) { return; } sortType = type; @@ -137,6 +189,9 @@ abstract class _TimelineController with Store { case 3: dayList.sort((a, b) => (b.votes).compareTo(a.votes)); break; + case 4: + dayList.sort((a, b) => a.airDate.compareTo(b.airDate)); + break; default: } } diff --git a/lib/pages/timeline/timeline_controller.g.dart b/lib/pages/timeline/timeline_controller.g.dart index 4e506af2a..db5b42fdd 100644 --- a/lib/pages/timeline/timeline_controller.g.dart +++ b/lib/pages/timeline/timeline_controller.g.dart @@ -73,6 +73,38 @@ mixin _$TimelineController on _TimelineController, Store { }); } + late final _$episodeCountsAtom = + Atom(name: '_TimelineController.episodeCounts', context: context); + + @override + ObservableMap get episodeCounts { + _$episodeCountsAtom.reportRead(); + return super.episodeCounts; + } + + @override + set episodeCounts(ObservableMap value) { + _$episodeCountsAtom.reportWrite(value, super.episodeCounts, () { + super.episodeCounts = value; + }); + } + + late final _$isLoadingEpisodesAtom = + Atom(name: '_TimelineController.isLoadingEpisodes', context: context); + + @override + bool get isLoadingEpisodes { + _$isLoadingEpisodesAtom.reportRead(); + return super.isLoadingEpisodes; + } + + @override + set isLoadingEpisodes(bool value) { + _$isLoadingEpisodesAtom.reportWrite(value, super.isLoadingEpisodes, () { + super.isLoadingEpisodes = value; + }); + } + late final _$notShowAbandonedBangumisAtom = Atom( name: '_TimelineController.notShowAbandonedBangumis', context: context); @@ -142,6 +174,15 @@ mixin _$TimelineController on _TimelineController, Store { }); } + late final _$fetchEpisodeCountsAsyncAction = + AsyncAction('_TimelineController.fetchEpisodeCounts', context: context); + + @override + Future fetchEpisodeCounts() { + return _$fetchEpisodeCountsAsyncAction + .run(() => super.fetchEpisodeCounts()); + } + late final _$setNotShowAbandonedBangumisAsyncAction = AsyncAction( '_TimelineController.setNotShowAbandonedBangumis', context: context); @@ -179,6 +220,8 @@ bangumiCalendar: ${bangumiCalendar}, seasonString: ${seasonString}, isLoading: ${isLoading}, isTimeOut: ${isTimeOut}, +episodeCounts: ${episodeCounts}, +isLoadingEpisodes: ${isLoadingEpisodes}, notShowAbandonedBangumis: ${notShowAbandonedBangumis}, notShowWatchedBangumis: ${notShowWatchedBangumis}, onlyShowWatchingBangumis: ${onlyShowWatchingBangumis} diff --git a/lib/pages/timeline/timeline_page.dart b/lib/pages/timeline/timeline_page.dart index f897b1d6c..a600f0b3a 100644 --- a/lib/pages/timeline/timeline_page.dart +++ b/lib/pages/timeline/timeline_page.dart @@ -13,6 +13,7 @@ import 'package:kazumi/utils/anime_season.dart'; import 'package:kazumi/bean/dialog/dialog_helper.dart'; import 'package:kazumi/bean/widget/bangumi_mirror_error_widget.dart'; import 'package:kazumi/utils/device.dart'; +import 'package:kazumi/bean/widget/collectable_card_wrapper.dart'; class TimelinePage extends StatefulWidget { const TimelinePage({super.key}); @@ -946,10 +947,14 @@ class _TimelinePageState extends State (BuildContext context, int index) { if (filteredList.isEmpty) return null; final item = filteredList[index]; - return BangumiTimelineCard( + return CollectableCardWrapper( bangumiItem: item, - cardHeight: cardHeight, - showRating: showRating); + child: BangumiTimelineCard( + bangumiItem: item, + cardHeight: cardHeight, + showRating: showRating, + episodeCount: + timelineController.episodeCounts[item.id])); }, childCount: filteredList.isNotEmpty ? filteredList.length : 10, diff --git a/lib/repositories/collect_crud_repository.dart b/lib/repositories/collect_crud_repository.dart index ac78f6fb7..35e113134 100644 --- a/lib/repositories/collect_crud_repository.dart +++ b/lib/repositories/collect_crud_repository.dart @@ -23,6 +23,9 @@ abstract class ICollectCrudRepository { /// 返回收藏类型值,未收藏返回0 int getCollectType(int id); + /// 更新收藏的剧集数 + Future updateCollectibleEps(int id, int eps); + /// 添加或更新收藏 /// /// [bangumiItem] 番剧信息 @@ -98,6 +101,22 @@ class CollectCrudRepository implements ICollectCrudRepository { } } + @override + Future updateCollectibleEps(int id, int eps) async { + try { + final collectible = _collectiblesBox.get(id); + if (collectible == null) return; + collectible.eps = eps; + await _collectiblesBox.put(id, collectible); + } catch (e, stackTrace) { + KazumiLogger().e( + 'GStorage: update collectible eps failed. id=$id, eps=$eps', + error: e, + stackTrace: stackTrace, + ); + } + } + @override Future addCollectible(BangumiItem bangumiItem, int type) async { try { diff --git a/lib/request/apis/bangumi_api.dart b/lib/request/apis/bangumi_api.dart index 6f8661c61..756e02ee9 100644 --- a/lib/request/apis/bangumi_api.dart +++ b/lib/request/apis/bangumi_api.dart @@ -29,6 +29,7 @@ class BangumiApi { for (dynamic jsonItem in jsonList) { try { BangumiItem bangumiItem = BangumiItem.fromJson(jsonItem['subject']); + bangumiItem.airWeekday = i; bangumiList.add(bangumiItem); } catch (_) {} } @@ -117,7 +118,9 @@ class BangumiApi { final subject = jsonItem is Map ? jsonItem['subject'] : null; if (subject is Map) { - bangumiList.add(BangumiItem.fromJson(subject)); + final item = BangumiItem.fromJson(subject); + item.airWeekday = i; + bangumiList.add(item); } } catch (_) {} } diff --git a/lib/utils/update_check_service.dart b/lib/utils/update_check_service.dart new file mode 100644 index 000000000..73fc9f748 --- /dev/null +++ b/lib/utils/update_check_service.dart @@ -0,0 +1,80 @@ +import 'package:hive_ce/hive.dart'; +import 'package:kazumi/modules/bangumi/episode_item.dart'; +import 'package:kazumi/modules/collect/collect_module.dart'; +import 'package:kazumi/modules/history/history_module.dart'; +import 'package:kazumi/repositories/collect_crud_repository.dart'; +import 'package:kazumi/request/apis/bangumi_api.dart'; +import 'package:kazumi/services/logging/logger.dart'; +import 'package:kazumi/services/storage/storage.dart'; +import 'package:flutter_modular/flutter_modular.dart'; + +class UpdateCheckService { + CollectCrudRepository? _crudRepo; + + CollectCrudRepository get _crud => + _crudRepo ??= Modular.get(); + + Future> checkUpdates( + List collectibles) async { + final Set bangumiIdsWithUpdate = {}; + final Box historiesBox = GStorage.histories; + + for (final collectible in collectibles) { + if (collectible.type != 1) continue; + + final int bangumiId = collectible.bangumiItem.id; + + if (collectible.eps == 0) { + await _fetchAndStoreEps(collectible); + } + + if (collectible.eps > 0) { + final int lastWatched = _getLastWatchedEpisode( + historiesBox, bangumiId); + if (lastWatched < collectible.eps) { + bangumiIdsWithUpdate.add(bangumiId); + } + } + } + + return bangumiIdsWithUpdate; + } + + Future _fetchAndStoreEps(CollectedBangumi collectible) async { + try { + final List episodes = + await BangumiApi.getBangumiEpisodesByID( + collectible.bangumiItem.id) + .timeout(const Duration(seconds: 15)); + final int eps = episodes.where((e) => e.type == 0).length; + if (eps > 0) { + collectible.eps = eps; + await _crud.updateCollectibleEps( + collectible.bangumiItem.id, eps); + } + } catch (e) { + KazumiLogger().w( + 'UpdateCheck: failed to fetch eps for ${collectible.bangumiItem.id}', + error: e, + ); + } + } + + int _getLastWatchedEpisode(Box historiesBox, int bangumiId) { + int maxEpisode = 0; + try { + for (final history in historiesBox.values.cast()) { + if (history.bangumiItem.id == bangumiId && + history.lastWatchEpisode > maxEpisode) { + maxEpisode = history.lastWatchEpisode; + } + } + } catch (e) { + KazumiLogger().w( + 'UpdateCheck: failed to get last watched for $bangumiId', + error: e, + ); + } + return maxEpisode; + } +}