diff --git a/lib/components/common_image.dart b/lib/components/common_image.dart index f44ca850..8d94e221 100644 --- a/lib/components/common_image.dart +++ b/lib/components/common_image.dart @@ -11,12 +11,16 @@ class CommonImage extends StatelessWidget { {this.showIconWhenUrlIsEmptyOrError = true, this.reduceMemCache = true, this.memCacheWidth = 600, + this.fit = BoxFit.cover, + this.alignment = Alignment.center, Key? key}) : super(key: key); final String url; final bool reduceMemCache; final int memCacheWidth; final bool showIconWhenUrlIsEmptyOrError; // 当没有图片或图片错误时,显示图标 + final BoxFit fit; + final Alignment alignment; @override Widget build(BuildContext context) { @@ -38,7 +42,8 @@ class CommonImage extends StatelessWidget { fadeInDuration: fadeInDuration, errorWidget: (_, __, ___) => _buildDefaultImage(context, isError: true), placeholder: (_, __) => _buildDefaultImage(context), - fit: BoxFit.cover, + fit: fit, + alignment: alignment, ); } @@ -56,7 +61,8 @@ class CommonImage extends StatelessWidget { ? ResizeImage(fileImage, width: memCacheWidth) as ImageProvider : fileImage, - fit: BoxFit.cover, + fit: fit, + alignment: alignment, fadeInDuration: fadeInDuration, placeholder: MemoryImage(kTransparentImage), imageErrorBuilder: (_, __, ___) => diff --git a/lib/controllers/theme_controller.dart b/lib/controllers/theme_controller.dart index 018b0786..28b940e3 100644 --- a/lib/controllers/theme_controller.dart +++ b/lib/controllers/theme_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test_future/models/page_switch_animation.dart'; +import 'package:flutter_test_future/utils/settings.dart'; import 'package:flutter_test_future/utils/sp_profile.dart'; import 'package:flutter_test_future/utils/sp_util.dart'; import 'package:flutter_test_future/values/values.dart'; @@ -12,6 +13,8 @@ class ThemeController extends GetxController { Rx useM3 = SPUtil.getBool("useM3", defaultValue: true).obs; Rx useCardStyle = SPUtil.getBool("useCardStyle", defaultValue: true).obs; + Rx hideMobileBottomLabel = + SettingsUtil.getValue(SettingsEnum.hideMobileBottomLabel).obs; Rx lightThemeColor = getSelectedTheme(); Rx darkThemeColor = getSelectedTheme(dark: true); diff --git a/lib/dao/history_dao.dart b/lib/dao/history_dao.dart index db2867d7..883104d8 100644 --- a/lib/dao/history_dao.dart +++ b/lib/dao/history_dao.dart @@ -112,8 +112,8 @@ class HistoryDao { Log.info('sql: getFirstHistory'); var cols = await SqliteUtil.database.rawQuery(''' - select date, anime_id from history - order by date limit 1; + select min(date) min_date, anime_id from history + where date not like '0000%'; '''); if (cols.isEmpty) return null; @@ -121,7 +121,7 @@ class HistoryDao { var anime = await SqliteUtil.getAnimeByAnimeId(col['anime_id'] as int); return { 'anime': anime, - 'date': col['date'], + 'date': col['min_date'], }; } @@ -140,4 +140,39 @@ class HistoryDao { } return animes; } + + /// 获取最大观看次数 + static Future getMaxReviewNumber(int animeId) async { + final rows = await SqliteUtil.database.rawQuery(''' + select max(review_number) max_review_number from history where anime_id = $animeId; + '''); + return SqliteUtil.firstRowColumnValue(rows) ?? 1; + } + + /// 获取指定回顾序号动漫的观看集数 + static Future getAnimeWatchedCount(int animeId, int reviewNumber) async { + final rows = await SqliteUtil.database.rawQuery(''' + select count(date) number from history + where anime_id = $animeId and review_number = $reviewNumber; + '''); + return SqliteUtil.firstRowColumnValue(rows) ?? 0; + } + + /// 获取当前观看次数的最早日期 + static Future getWatchedMinDate(int animeId, int reviewNumber) async { + final rows = await SqliteUtil.database.rawQuery(''' + select min(date) from history + where anime_id = $animeId and review_number = $reviewNumber and date not like '0000%'; + '''); + return SqliteUtil.firstRowColumnValue(rows) ?? ''; + } + + /// 获取当前观看次数的最晚日期 + static Future getWatchedMaxDate(int animeId, int reviewNumber) async { + final rows = await SqliteUtil.database.rawQuery(''' + select max(date) from history + where anime_id = $animeId and review_number = $reviewNumber and date not like '0000%'; + '''); + return SqliteUtil.firstRowColumnValue(rows) ?? ''; + } } diff --git a/lib/main.dart b/lib/main.dart index 5c42a68e..b3dc7e8f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,7 +7,6 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:bot_toast/bot_toast.dart'; import 'package:flex_color_scheme/flex_color_scheme.dart'; import 'package:flutter_logkit/logkit.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_test_future/components/classic_refresh_style.dart'; import 'package:flutter_test_future/controllers/backup_service.dart'; import 'package:flutter_test_future/global.dart'; @@ -98,7 +97,6 @@ class MyApp extends StatefulWidget { class MyAppState extends State { final ThemeController themeController = Get.put(ThemeController()); - FlexScheme get baseScheme => FlexScheme.blue; TextStyle get textStyle => TextStyle(fontFamilyFallback: themeController.fontFamilyFallback); ThemeColor get curLightThemeColor => themeController.lightThemeColor.value; @@ -114,11 +112,8 @@ class MyAppState extends State { return GetMaterialApp( home: LogkitOverlayAttacher( logger: logger, - child: WindowWrapper( - child: ScreenUtilInit( - designSize: const Size(375, 812), - builder: (context, child) => const MainScreen(), - ), + child: const WindowWrapper( + child: MainScreen(), ), ), debugShowCheckedModeBanner: false, @@ -135,10 +130,10 @@ class MyAppState extends State { child = BotToastInit()(context, child); // 全局点击空白处隐藏软键盘 child = _buildScaffoldWithHideKeyboardByClickBlank(context, child); - return Theme( - data: _getFixedTheme(context), - child: child, - ); + return Obx(() => Theme( + data: _getFixedTheme(context), + child: child ?? const SizedBox(), + )); }, navigatorObservers: [BotToastNavigatorObserver()], // 后台应用显示名称 @@ -240,12 +235,11 @@ class MyAppState extends State { curLightThemeColor.primaryColor; return FlexThemeData.light( - scheme: baseScheme, + colorScheme: ColorScheme.fromSeed( + seedColor: primary, brightness: Brightness.light), useMaterial3: themeController.useM3.value, fontFamilyFallback: textStyle.fontFamilyFallback, primary: primary, - primaryContainer: primary.withOpacity(0.6), - tertiaryContainer: primary.withOpacity(0.4), scaffoldBackground: curLightThemeColor.bodyColor, surface: curLightThemeColor.cardColor, // BottomNavigationBar @@ -259,7 +253,7 @@ class MyAppState extends State { subThemesData: FlexSubThemesData( // chip颜色 chipSchemeColor: SchemeColor.primaryContainer, - chipSelectedSchemeColor: SchemeColor.primary, + chipSelectedSchemeColor: SchemeColor.secondaryContainer, useM2StyleDividerInM3: true, // 悬浮、按压等颜色不受主颜色影响 interactionEffects: false, @@ -306,12 +300,13 @@ class MyAppState extends State { curDarkThemeColor.primaryColor; return FlexThemeData.dark( - scheme: baseScheme, + colorScheme: ColorScheme.fromSeed( + seedColor: primary, + onPrimary: Colors.white, + brightness: Brightness.dark), useMaterial3: themeController.useM3.value, fontFamilyFallback: textStyle.fontFamilyFallback, primary: primary, - primaryContainer: primary.withOpacity(0.6), - tertiaryContainer: primary.withOpacity(0.4), scaffoldBackground: curDarkThemeColor.bodyColor, surface: curDarkThemeColor.cardColor, // BottomNavigationBar @@ -325,7 +320,7 @@ class MyAppState extends State { subThemesData: FlexSubThemesData( // chip颜色 chipSchemeColor: SchemeColor.primaryContainer, - chipSelectedSchemeColor: SchemeColor.tertiaryContainer, + chipSelectedSchemeColor: SchemeColor.secondaryContainer, // 悬浮、按压等颜色不受主颜色影响 interactionEffects: false, useTextTheme: true, diff --git a/lib/models/bangumi/bangumi.dart b/lib/models/bangumi/bangumi.dart new file mode 100644 index 00000000..cb4f7e25 --- /dev/null +++ b/lib/models/bangumi/bangumi.dart @@ -0,0 +1,4 @@ +export 'character.dart'; +export 'images.dart'; +export 'person.dart'; +export 'person_career.dart'; diff --git a/lib/models/bangumi/character.dart b/lib/models/bangumi/character.dart new file mode 100644 index 00000000..6cea951c --- /dev/null +++ b/lib/models/bangumi/character.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; + +import 'bangumi.dart'; + +class RelatedCharacter { + Images? images; + String? name; + String? relation; + List? actors; + int? type; + int? id; + + RelatedCharacter({ + this.images, + this.name, + this.relation, + this.actors, + this.type, + this.id, + }); + + factory RelatedCharacter.fromJson(String str) => + RelatedCharacter.fromMap(json.decode(str)); + + String toJson() => json.encode(toMap()); + + factory RelatedCharacter.fromMap(Map json) => + RelatedCharacter( + images: json["images"] == null ? null : Images.fromMap(json["images"]), + name: json["name"], + relation: json["relation"], + actors: json["actors"] == null + ? [] + : List.from( + json["actors"]!.map((x) => RelatedPerson.fromMap(x))), + type: json["type"], + id: json["id"], + ); + + Map toMap() => { + "images": images?.toMap(), + "name": name, + "relation": relation, + "actors": actors == null + ? [] + : List.from(actors!.map((x) => x.toMap())), + "type": type, + "id": id, + }; +} diff --git a/lib/models/bangumi/images.dart b/lib/models/bangumi/images.dart new file mode 100644 index 00000000..3e715a16 --- /dev/null +++ b/lib/models/bangumi/images.dart @@ -0,0 +1,33 @@ +import 'dart:convert'; + +class Images { + String? small; + String? grid; + String? large; + String? medium; + + Images({ + this.small, + this.grid, + this.large, + this.medium, + }); + + factory Images.fromJson(String str) => Images.fromMap(json.decode(str)); + + String toJson() => json.encode(toMap()); + + factory Images.fromMap(Map json) => Images( + small: json["small"], + grid: json["grid"], + large: json["large"], + medium: json["medium"], + ); + + Map toMap() => { + "small": small, + "grid": grid, + "large": large, + "medium": medium, + }; +} diff --git a/lib/models/bangumi/person.dart b/lib/models/bangumi/person.dart new file mode 100644 index 00000000..919971f5 --- /dev/null +++ b/lib/models/bangumi/person.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; + +import 'bangumi.dart'; + +class RelatedPerson { + Images? images; + String? name; + String? shortSummary; + List? career; + int? id; + int? type; + bool? locked; + + RelatedPerson({ + this.images, + this.name, + this.shortSummary, + this.career, + this.id, + this.type, + this.locked, + }); + + factory RelatedPerson.fromJson(String str) => + RelatedPerson.fromMap(json.decode(str)); + + String toJson() => json.encode(toMap()); + + factory RelatedPerson.fromMap(Map json) => RelatedPerson( + images: json["images"] == null ? null : Images.fromMap(json["images"]), + name: json["name"], + shortSummary: json["short_summary"], + career: json["career"] == null + ? [] + : List.from(json["career"]!.map((x) => x)), + id: json["id"], + type: json["type"], + locked: json["locked"], + ); + + Map toMap() => { + "images": images?.toMap(), + "name": name, + "short_summary": shortSummary, + "career": + career == null ? [] : List.from(career!.map((x) => x)), + "id": id, + "type": type, + "locked": locked, + }; +} diff --git a/lib/models/bangumi/person_career.dart b/lib/models/bangumi/person_career.dart new file mode 100644 index 00000000..588636e1 --- /dev/null +++ b/lib/models/bangumi/person_career.dart @@ -0,0 +1,9 @@ +enum PersonCareer { + producer, + mangaka, + artist, + seiyu, + writer, + illustrator, + actor +} diff --git a/lib/models/params/result.dart b/lib/models/params/result.dart index 25101826..fd8e4308 100644 --- a/lib/models/params/result.dart +++ b/lib/models/params/result.dart @@ -1,3 +1,4 @@ +import 'package:dio/dio.dart'; import 'package:flutter_test_future/utils/log.dart'; class Result { @@ -25,60 +26,103 @@ class Result { } } -extension ResultConverter on Result { - bool _isMap(dynamic value) => value is Map; - bool _isNotMap(dynamic value) => !_isMap(value); +bool _isMap(dynamic value) => value is Map; +bool _isNotMap(dynamic value) => !_isMap(value); +enum ResultDataType { + body, + bodyData, + responseBody; +} + +extension ResultDataTypeExtension on ResultDataType { + R? extract(Result result) { + switch (this) { + case ResultDataType.body: + return _extractBody(result); + case ResultDataType.bodyData: + return _extractBodyData(result); + case ResultDataType.responseBody: + return _extractResponseBody(result); + } + } + + R? _extractBodyData(Result result) { + if (result.isFailure || _isNotMap(result.data)) return null; + final data = result.data['data']; + if (data is! R) return null; + return data; + } + + R? _extractBody(Result result) { + if (result.isFailure) return null; + final data = result.data; + if (data is! R) return null; + return data; + } + + R? _extractResponseBody(Result result) { + if (result.isFailure) return null; + if (result.data is! Response) return null; + final data = (result.data as Response).data; + if (data is! R) return null; + return data; + } +} + +extension ResponseDataTransformer on Result { T toModel({ required T Function(Map json) transform, - required T Function() dataOnError, + required T Function() onError, + ResultDataType dataType = ResultDataType.body, }) { - if (isFailure || _isNotMap(data) || _isNotMap(data['data'])) { - return dataOnError(); - } + final innerData = dataType.extract>(this); + if (innerData == null) return onError(); try { - return transform(data['data']); + return transform(innerData); } catch (e) { - return dataOnError(); + return onError(); } } List toModelList({ required T Function(Map json) transform, - List Function()? dataOnError, + List Function()? onError, + ResultDataType dataType = ResultDataType.body, }) { - if (isFailure || _isNotMap(data) || data['data'] is! List) { - return dataOnError?.call() ?? []; - } + final data = dataType.extract>(this); + if (data == null) return onError?.call() ?? []; List list = []; - for (final item in data['data']) { + for (final item in data) { if (_isNotMap(item)) continue; try { list.add(transform(item)); - } catch (e) { - Log.error(e); + } catch (err, stack) { + logger.error('transfrom异常:$err', stackTrace: stack); } } return list; } T? toValue({ - T? Function()? dataOnError, + T? Function()? onError, + ResultDataType dataType = ResultDataType.body, }) { - if (isFailure || _isNotMap(data) || data['data'] is! T) { - return dataOnError?.call(); - } - return data['data']; + final data = dataType.extract(this); + if (data == null) return onError?.call(); + + return data; } List toValueList({ - List Function()? dataOnError, + List Function()? onError, + ResultDataType dataType = ResultDataType.body, }) { - if (isFailure || _isNotMap(data) || data['data'] is! List) { - return dataOnError?.call() ?? []; - } - return data['data']; + final data = dataType.extract>(this); + if (data == null) return onError?.call() ?? []; + + return data; } } diff --git a/lib/models/review_info.dart b/lib/models/review_info.dart new file mode 100644 index 00000000..aa330f69 --- /dev/null +++ b/lib/models/review_info.dart @@ -0,0 +1,18 @@ +class ReviewInfo { + final int number; + final int checked; + final int total; + final String minDate; + final String maxDate; + ReviewInfo({ + required this.number, + required this.checked, + required this.total, + required this.minDate, + required this.maxDate, + }); + + factory ReviewInfo.empty({required int number, required int total}) => + ReviewInfo( + number: number, checked: 0, total: total, minDate: '', maxDate: ''); +} diff --git a/lib/pages/anime_collection/anime_list_page.dart b/lib/pages/anime_collection/anime_list_page.dart index 61f9e71b..d63f882d 100644 --- a/lib/pages/anime_collection/anime_list_page.dart +++ b/lib/pages/anime_collection/anime_list_page.dart @@ -21,6 +21,7 @@ import 'package:flutter_test_future/values/values.dart'; import 'package:flutter_test_future/widgets/bottom_sheet.dart'; import 'package:flutter_test_future/widgets/common_scaffold_body.dart'; import 'package:flutter_test_future/widgets/common_tab_bar_view.dart'; +import 'package:flutter_test_future/widgets/floating_bottom_actions.dart'; import 'package:get/get.dart'; import 'package:flutter_test_future/utils/log.dart'; @@ -266,7 +267,7 @@ class _AnimeListPageState extends State { ) : animeView, // 一定要叠放在ListView上面,否则点击按钮没有反应 - _buildBottomButton(checklistIdx), + _buildBottomActions(checklistIdx), ]), ), ); @@ -535,40 +536,24 @@ class _AnimeListPageState extends State { return list; } - _buildBottomButton(int checklistIdx) { - return !multiSelected - ? Container() - : Container( - alignment: Alignment.bottomCenter, - child: Card( - elevation: 8, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(50))), - // 圆角 - clipBehavior: Clip.antiAlias, - // 设置抗锯齿,实现圆角背景 - margin: const EdgeInsets.fromLTRB(80, 20, 80, 20), - child: Row( - // mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: IconButton( - onPressed: () { - _dialogModifyTag(tags[checklistIdx]); - }, - icon: const Icon(Icons.checklist), - ), - ), - Expanded( - child: IconButton( - onPressed: () => _dialogDeleteAnime(checklistIdx), - icon: const Icon(Icons.delete_outline), - ), - ), - ], - ), - ), - ); + Widget _buildBottomActions(int checklistIdx) { + return FloatingBottomActions( + display: multiSelected, + itemPadding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + IconButton( + onPressed: () { + _dialogModifyTag(tags[checklistIdx]); + }, + icon: const Icon(Icons.checklist), + tooltip: '移动清单', + ), + IconButton( + onPressed: () => _dialogDeleteAnime(checklistIdx), + icon: const Icon(Icons.delete_outline), + tooltip: '删除动漫', + ), + ]); } _dialogDeleteAnime(int checklistIdx) { diff --git a/lib/pages/anime_detail/anime_detail.dart b/lib/pages/anime_detail/anime_detail.dart index b137869a..09985d01 100644 --- a/lib/pages/anime_detail/anime_detail.dart +++ b/lib/pages/anime_detail/anime_detail.dart @@ -14,8 +14,11 @@ import 'package:flutter_test_future/pages/viewer/video/view_with_load_url.dart'; import 'package:flutter_test_future/utils/climb/climb_anime_util.dart'; import 'package:flutter_test_future/utils/log.dart'; import 'package:flutter_test_future/utils/platform.dart'; +import 'package:flutter_test_future/utils/time_util.dart'; +import 'package:flutter_test_future/widgets/floating_bottom_actions.dart'; import 'package:flutter_test_future/widgets/multi_platform.dart'; import 'package:get/get.dart'; +import 'package:ming_cute_icons/ming_cute_icons.dart'; class AnimeDetailPage extends StatefulWidget { final Anime anime; @@ -246,64 +249,52 @@ class _AnimeDetailPageState extends State { /// 显示底部集多选操作栏 _buildButtonsBarAboutEpisodeMulti() { - if (!animeController.multiSelected.value) return Container(); - - return Container( - alignment: Alignment.bottomCenter, - child: Card( - elevation: 8, - // 圆角 - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(20))), - // 设置抗锯齿,实现圆角背景 - clipBehavior: Clip.antiAlias, - margin: const EdgeInsets.fromLTRB(80, 20, 80, 20), - child: Row( - // mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: IconButton( - onPressed: () { - if (animeController.mapSelected.length == - animeController.episodes.length) { - // 全选了,点击则会取消全选 - animeController.mapSelected.clear(); - } else { - // 其他情况下,全选 - for (int j = 0; j < animeController.episodes.length; ++j) { - animeController.mapSelected[j] = true; - } - } - // 不重绘整个详情页面 - // setState(() {}); - // 只重绘集页面 - animeController.update([animeController.episodeId]); - }, - icon: const Icon(Icons.select_all_rounded), - tooltip: "全选", - ), - ), - Expanded( - child: IconButton( - onPressed: () async { - await animeController.pickDateForEpisodes(context: context); - // 退出多选模式 - animeController.quitMultiSelectionMode(); - }, - icon: const Icon(Icons.access_time), - tooltip: "设置观看时间", - ), - ), - Expanded( - child: IconButton( - onPressed: () => animeController.quitMultiSelectionMode(), - icon: const Icon(Icons.exit_to_app), - tooltip: "退出多选", - ), - ), - ], + return FloatingBottomActions( + display: animeController.multiSelected.value, + children: [ + IconButton( + onPressed: () { + if (animeController.mapSelected.length == + animeController.episodes.length) { + // 全选了,点击则会取消全选 + animeController.mapSelected.clear(); + } else { + // 其他情况下,全选 + for (int j = 0; j < animeController.episodes.length; ++j) { + animeController.mapSelected[j] = true; + } + } + // 不重绘整个详情页面 + // setState(() {}); + // 只重绘集页面 + animeController.update([animeController.episodeId]); + }, + icon: const Icon(Icons.select_all_rounded), + tooltip: "全选", ), - ), + IconButton( + onPressed: () async { + await animeController.pickDateForEpisodes(context: context); + animeController.quitMultiSelectionMode(); + }, + icon: const Icon(MingCuteIcons.mgc_calendar_time_add_line), + tooltip: "设置观看时间", + ), + IconButton( + onPressed: () async { + await animeController.pickDateForEpisodes( + context: context, dateTime: TimeUtil.unRecordedDateTime); + animeController.quitMultiSelectionMode(); + }, + icon: const Icon(MingCuteIcons.mgc_check_circle_line), + tooltip: "仅标记完成", + ), + IconButton( + onPressed: () => animeController.quitMultiSelectionMode(), + icon: const Icon(Icons.exit_to_app), + tooltip: "退出多选", + ), + ], ); } diff --git a/lib/pages/anime_detail/controllers/anime_controller.dart b/lib/pages/anime_detail/controllers/anime_controller.dart index ac89c540..ec568bb7 100644 --- a/lib/pages/anime_detail/controllers/anime_controller.dart +++ b/lib/pages/anime_detail/controllers/anime_controller.dart @@ -250,16 +250,31 @@ class AnimeController extends GetxController { // 多选后,选择日期,并更新数据库 // 尾部的选择日期按钮也可以使用该方法,记得提前加入到多选中 - Future pickDateForEpisodes( - {required BuildContext context, DateTime? initialValue}) async { - DateTime? dateTime = await showCommonDateTimePicker( - context: context, - initialValue: initialValue ?? DateTime.now(), - minYear: 1970, - maxYear: DateTime.now().year + 2, - ); - if (dateTime == null) return; - final dateTimeStr = dateTime.toString(); + Future pickDateForEpisodes({ + required BuildContext context, + DateTime? dateTime, + DateTime? initialDateTime, + }) async { + DateTime? selectedDateTime; + + if (dateTime == null) { + // 未指定日期时,弹出日期选择器 + const minYear = 1970; + final initialValue = initialDateTime != null && + initialDateTime.compareTo(DateTime(minYear)) > 0 + ? initialDateTime + : DateTime.now(); + selectedDateTime = await showCommonDateTimePicker( + context: context, + initialValue: initialValue, + minYear: minYear, + maxYear: DateTime.now().year + 2, + ); + if (selectedDateTime == null) return; + } else { + selectedDateTime = dateTime; + } + final dateTimeStr = selectedDateTime.toString(); // 遍历选中的下标 mapSelected.forEach((episodeIndex, value) { @@ -488,7 +503,11 @@ class AnimeController extends GetxController { }, child: ClipRRect( borderRadius: BorderRadius.circular(AppTheme.imgRadius), - child: SizedBox(width: 200, child: CommonImage(coverUrl)), + child: SizedBox( + height: 260, + width: 200, + child: CommonImage(coverUrl), + ), ), ), ], diff --git a/lib/pages/anime_detail/widgets/episode.dart b/lib/pages/anime_detail/widgets/episode.dart index cdcb95e5..1475546d 100644 --- a/lib/pages/anime_detail/widgets/episode.dart +++ b/lib/pages/anime_detail/widgets/episode.dart @@ -1,12 +1,13 @@ + import 'package:eva_icons_flutter/eva_icons_flutter.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_test_future/components/dialog/dialog_select_uint.dart'; import 'package:flutter_test_future/components/loading_widget.dart'; import 'package:flutter_test_future/dao/anime_dao.dart'; import 'package:flutter_test_future/pages/anime_detail/controllers/anime_controller.dart'; import 'package:flutter_test_future/models/anime.dart'; import 'package:flutter_test_future/models/episode.dart'; import 'package:flutter_test_future/pages/anime_detail/widgets/episode_item_auto_load_note.dart'; +import 'package:flutter_test_future/pages/anime_detail/widgets/review_infos.dart'; import 'package:flutter_test_future/utils/episode.dart'; import 'package:flutter_test_future/utils/log.dart'; import 'package:flutter_test_future/utils/sp_util.dart'; @@ -289,17 +290,23 @@ class _AnimeDetailEpisodeInfoState extends State { // } void _dialogSelectReviewNumber() { - dialogSelectUint(context, "选择第几次观看", - initialValue: _anime.reviewNumber, minValue: 1, maxValue: 99) - .then((value) { - if (value != null) { - if (_anime.reviewNumber != value) { - _anime.reviewNumber = value; - AnimeDao.updateReviewNumber(_anime.animeId, value); - // 不相等才设置并重新加载数据 - widget.animeController.loadEpisode(); - } - } - }); + loadReviewNumber(int value) { + if (_anime.reviewNumber == value) return; + + _anime.reviewNumber = value; + AnimeDao.updateReviewNumber(_anime.animeId, value); + widget.animeController.loadEpisode(); + } + + showCommonModalBottomSheet( + context: context, + builder: (context) => AnimeReviewInfoView( + anime: _anime, + onSelect: (reviewNumber) { + Navigator.pop(context); + loadReviewNumber(reviewNumber); + }, + ), + ); } } diff --git a/lib/pages/anime_detail/widgets/episode_item_auto_load_note.dart b/lib/pages/anime_detail/widgets/episode_item_auto_load_note.dart index f6f74cd9..febd269b 100644 --- a/lib/pages/anime_detail/widgets/episode_item_auto_load_note.dart +++ b/lib/pages/anime_detail/widgets/episode_item_auto_load_note.dart @@ -17,6 +17,7 @@ import 'package:flutter_test_future/utils/log.dart'; import 'package:flutter_test_future/utils/platform.dart'; import 'package:flutter_test_future/utils/sp_util.dart'; import 'package:flutter_test_future/utils/sqlite_util.dart'; +import 'package:flutter_test_future/utils/time_util.dart'; import 'package:flutter_test_future/values/values.dart'; import 'package:flutter_test_future/utils/toast_util.dart'; import 'package:flutter_test_future/widgets/common_divider.dart'; @@ -171,14 +172,7 @@ class _EpisodeItemAutoLoadNoteState extends State { ), ), // 没有完成时不显示subtitle - subtitle: widget.episode.isChecked() - ? Text( - widget.episode.getDate(), - style: - TextStyle(color: _episode.isChecked() ? checkedColor : null), - // style: Theme.of(context).textTheme.bodySmall, - ) - : null, + subtitle: _buildSubtitle(), onTap: () => onpressEpisode(), onLongPress: () => onLongPressEpisode(), leading: _buildLeading(), @@ -186,6 +180,16 @@ class _EpisodeItemAutoLoadNoteState extends State { ); } + Text? _buildSubtitle() { + if (!widget.episode.isChecked()) return null; + final dateStr = widget.episode.getDate(); + if (dateStr.isEmpty) return null; + return Text( + dateStr, + style: TextStyle(color: _episode.isChecked() ? checkedColor : null), + ); + } + _buildEpisodeTileTrailing() { // 如果还在加载笔记,则不显示更多按钮,避免打开后创建笔记 if (_loadingNote) { @@ -277,23 +281,17 @@ class _EpisodeItemAutoLoadNoteState extends State { onTap: () async { // 退出对话框 Navigator.of(dialogContext).pop(); - - // 如果是多选状态则先退出 - if (widget.animeController.multiSelected.value) { - widget.animeController.quitMultiSelectionMode(); - } - // 添加到多选中,保证只有这一个 - widget.animeController.mapSelected[widget.episodeIndex] = - true; - // 选择时间 - await widget.animeController.pickDateForEpisodes( - context: context, - initialValue: DateTime.tryParse(_episode.dateTime ?? ''), - ); - // 清空多选 - widget.animeController.mapSelected.clear(); - // 更新设置的时间 - setState(() {}); + completeEpisode(); + }, + ), + ListTile( + title: const Text("仅标记完成"), + leading: + const Icon(MingCuteIcons.mgc_check_circle_line, size: 22), + onTap: () async { + // 退出对话框 + Navigator.of(dialogContext).pop(); + completeEpisode(dateTime: TimeUtil.unRecordedDateTime); }, ), if (_episode.isChecked()) @@ -349,6 +347,24 @@ class _EpisodeItemAutoLoadNoteState extends State { }); } + Future completeEpisode({DateTime? dateTime}) async { + // 如果是多选状态则先退出 + if (widget.animeController.multiSelected.value) { + widget.animeController.quitMultiSelectionMode(); + } + // 添加到多选中,保证只有这一个 + widget.animeController.mapSelected[widget.episodeIndex] = true; + await widget.animeController.pickDateForEpisodes( + context: context, + dateTime: dateTime, + initialDateTime: DateTime.tryParse(_episode.dateTime ?? ''), + ); + // 清空多选 + widget.animeController.mapSelected.clear(); + // 更新设置的时间 + setState(() {}); + } + getPreviewCaption(int number, String title, bool hideDefault) { if (hideDefault) { return title; diff --git a/lib/pages/anime_detail/widgets/info.dart b/lib/pages/anime_detail/widgets/info.dart index 629ccfdb..3c11b2ca 100644 --- a/lib/pages/anime_detail/widgets/info.dart +++ b/lib/pages/anime_detail/widgets/info.dart @@ -5,6 +5,7 @@ import 'package:flutter_test_future/components/dialog/dialog_select_checklist.da import 'package:flutter_test_future/components/dialog/dialog_select_play_status.dart'; import 'package:flutter_test_future/dao/anime_dao.dart'; import 'package:flutter_test_future/pages/anime_collection/checklist_controller.dart'; +import 'package:flutter_test_future/pages/bangumi/subject_detail/view.dart'; import 'package:flutter_test_future/pages/local_search/views/local_search_page.dart'; import 'package:flutter_test_future/pages/anime_detail/controllers/anime_controller.dart'; import 'package:flutter_test_future/models/anime.dart'; @@ -12,6 +13,7 @@ import 'package:flutter_test_future/pages/anime_detail/pages/anime_properties_pa import 'package:flutter_test_future/pages/anime_detail/pages/anime_rate_list_page.dart'; import 'package:flutter_test_future/pages/anime_detail/widgets/labels.dart'; import 'package:flutter_test_future/pages/settings/series/manage/view.dart'; +import 'package:flutter_test_future/routes/get_route.dart'; import 'package:flutter_test_future/utils/common_util.dart'; import 'package:flutter_test_future/utils/launch_uri_util.dart'; import 'package:flutter_test_future/utils/log.dart'; @@ -176,6 +178,7 @@ class _AnimeDetailInfoState extends State { }); }), _buildSearchBtn(), + // _buildBangumiInfoBtn(), ]; } @@ -226,6 +229,16 @@ class _AnimeDetailInfoState extends State { ); } + Widget _buildBangumiInfoBtn() { + return _buildIconTextButton( + iconData: MingCuteIcons.mgc_profile_line, + text: '信息', + onTap: () { + RouteUtil.materialTo(context, const BangumiSubjectDetailPage()); + }, + ); + } + _buildSearchBtn() { return _buildIconTextButton( iconData: MingCuteIcons.mgc_search_line, diff --git a/lib/pages/anime_detail/widgets/review_infos.dart b/lib/pages/anime_detail/widgets/review_infos.dart new file mode 100644 index 00000000..98f8665f --- /dev/null +++ b/lib/pages/anime_detail/widgets/review_infos.dart @@ -0,0 +1,143 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test_future/components/loading_widget.dart'; +import 'package:flutter_test_future/dao/history_dao.dart'; +import 'package:flutter_test_future/models/anime.dart'; +import 'package:flutter_test_future/models/data_state.dart'; +import 'package:flutter_test_future/models/review_info.dart'; +import 'package:flutter_test_future/utils/time_util.dart'; + +class AnimeReviewInfoView extends StatefulWidget { + const AnimeReviewInfoView( + {super.key, required this.anime, required this.onSelect}); + final Anime anime; + final void Function(int reviewNumber) onSelect; + + @override + State createState() => _AnimeReviewInfoViewState(); +} + +class _AnimeReviewInfoViewState extends State { + var reviewState = DataState>.loading(); + + @override + void initState() { + super.initState(); + _loadAllReviewInfos().then((value) { + setState(() { + reviewState = reviewState.toData(value); + }); + }).onError((error, stackTrace) { + setState(() { + reviewState = reviewState.toError( + error: error ?? Exception('加载错误'), + stackTrace: stackTrace, + ); + }); + }); + } + + Future> _loadAllReviewInfos() async { + final watchedReviewInfos = await _getReviewInfos(); + // 将历史的最大观看次数和当前选择的观看次数比较,避免无法多次点击新增按钮 + // 例如当前获取的历史最大次数为1,点击新增按钮后次数为2,而历史的最大次数仍然为1,再次点击新增按钮可设置次数为3 + final maxReviewNumber = + max(watchedReviewInfos.length, widget.anime.reviewNumber); + + final allReviewInfos = [...watchedReviewInfos]; + // 填充临时添加的没有任何记录的ReviewInfo + for (int i = 0; i < maxReviewNumber - watchedReviewInfos.length; i++) { + final lastReviewNumber = + allReviewInfos.isEmpty ? 0 : allReviewInfos.last.number; + allReviewInfos.add(ReviewInfo.empty( + number: lastReviewNumber + 1, total: widget.anime.animeEpisodeCnt)); + } + return allReviewInfos; + } + + Future> _getReviewInfos() async { + final animeId = widget.anime.animeId; + final maxReviewNumber = await HistoryDao.getMaxReviewNumber(animeId); + final List reviewInfos = []; + for (int reviewNumber = 1; + reviewNumber <= maxReviewNumber; + reviewNumber++) { + reviewInfos.add(ReviewInfo( + number: reviewNumber, + checked: await HistoryDao.getAnimeWatchedCount(animeId, reviewNumber), + total: widget.anime.animeEpisodeCnt, + minDate: TimeUtil.getYMD( + await HistoryDao.getWatchedMinDate(animeId, reviewNumber)), + maxDate: TimeUtil.getYMD( + await HistoryDao.getWatchedMaxDate(animeId, reviewNumber)), + )); + } + return reviewInfos; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('回顾'), + automaticallyImplyLeading: false, + actions: [ + if (reviewState.hasValue) + TextButton( + onPressed: () => + widget.onSelect((reviewState.value?.last.number ?? 0) + 1), + child: const Text('添加')) + ], + ), + body: reviewState.when( + data: (data) => _buildListView(data), + error: (error, stackTrace, message) => + Center(child: Text(error.toString())), + loading: (message) => const LoadingWidget(), + ), + ); + } + + ListView _buildListView(List reviewInfos) { + return ListView.builder( + padding: const EdgeInsets.only(bottom: 24), + itemCount: reviewInfos.length, + itemBuilder: (context, index) { + final info = reviewInfos[index]; + final selected = widget.anime.reviewNumber == info.number; + return ListTile( + leading: Container( + height: 30, + width: 30, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.primaryContainer), + child: Center( + child: Text( + info.number.toString(), + style: TextStyle( + color: selected + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onPrimaryContainer), + ), + ), + ), + selected: selected, + title: Text('${info.checked} / ${info.total}'), + subtitle: _buildDateRange(info), + onTap: () => widget.onSelect(info.number), + ); + }, + ); + } + + Text? _buildDateRange(ReviewInfo info) { + if (info.minDate.isEmpty && info.maxDate.isEmpty) return null; + return Text(info.minDate == info.maxDate + ? info.minDate + : '${info.minDate} ~ ${info.maxDate}'); + } +} diff --git a/lib/pages/bangumi/subject_detail/logic.dart b/lib/pages/bangumi/subject_detail/logic.dart new file mode 100644 index 00000000..fa749afd --- /dev/null +++ b/lib/pages/bangumi/subject_detail/logic.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test_future/repositories/bangumi_repository.dart'; +import 'package:flutter_test_future/models/bangumi/bangumi.dart'; +import 'package:get/get.dart'; + +class BangumiSubjectDetailLogic extends GetxController { + String subjectId = '400602'; + final repository = BangumiRepository(); + List characters = []; + + @override + void onInit() { + super.onInit(); + loadData(); + } + + Future loadData() async { + characters = await repository.fetchSubjectCharacters(subjectId); + characters.sort((a, b) => + _getReleationPriority(b.relation) - _getReleationPriority(a.relation)); + update(); + } + + int _getReleationPriority(String? relation) { + switch (relation) { + case '主角': + return 3; + case '配角': + return 2; + case '客串': + return 1; + default: + return 0; + } + } +} diff --git a/lib/pages/bangumi/subject_detail/view.dart b/lib/pages/bangumi/subject_detail/view.dart new file mode 100644 index 00000000..4dc573be --- /dev/null +++ b/lib/pages/bangumi/subject_detail/view.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test_future/components/common_image.dart'; +import 'package:flutter_test_future/pages/bangumi/subject_detail/logic.dart'; +import 'package:flutter_test_future/utils/string.dart'; +import 'package:flutter_test_future/values/theme.dart'; +import 'package:get/get.dart' hide GetDynamicUtils; + +class BangumiSubjectDetailPage extends StatefulWidget { + const BangumiSubjectDetailPage({super.key}); + + @override + State createState() => + BangumiSubjectDetailPageState(); +} + +class BangumiSubjectDetailPageState extends State { + final logic = Get.put(BangumiSubjectDetailLogic()); + + @override + void dispose() { + super.dispose(); + Get.delete(); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: logic, + builder: (_) { + return Scaffold( + appBar: AppBar(), + body: SingleChildScrollView( + child: Column( + children: [ + GridView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, mainAxisExtent: 60), + children: [ + for (final character in logic.characters + // .sublist(0, 8.clamp(0, logic.characters.length)) + ) + ListTile( + leading: ClipRRect( + borderRadius: + BorderRadius.circular(AppTheme.imgRadius), + child: SizedBox( + height: 40, + width: 40, + child: CommonImage( + character.images?.grid ?? '', + alignment: Alignment.topCenter, + ), + ), + ), + title: Text(character.name ?? ''), + subtitle: Row( + children: [ + if (!character.relation.isNullOrBlank) + Text('${character.relation} · '), + Expanded( + child: Text( + character.actors?.isNotEmpty == true + ? character.actors! + .map((e) => e.name ?? '') + .where((name) => name.isNotEmpty) + .join(' / ') + : '', + overflow: TextOverflow.ellipsis, + ), + ) + ], + ), + ) + ], + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/pages/history/history_page.dart b/lib/pages/history/history_page.dart index d4a572f7..7d786a20 100644 --- a/lib/pages/history/history_page.dart +++ b/lib/pages/history/history_page.dart @@ -10,6 +10,7 @@ import 'package:flutter_test_future/pages/anime_detail/anime_detail.dart'; import 'package:flutter_test_future/pages/history/history_controller.dart'; import 'package:flutter_test_future/utils/log.dart'; import 'package:flutter_test_future/utils/sp_util.dart'; +import 'package:flutter_test_future/utils/time_util.dart'; import 'package:flutter_test_future/values/theme.dart'; import 'package:flutter_test_future/widgets/common_divider.dart'; import 'package:flutter_test_future/widgets/common_scaffold_body.dart'; @@ -179,7 +180,9 @@ class _HistoryPageState extends State { children: [ // 卡片标题 SettingTitle( - title: date.replaceAll("-", "/"), + title: TimeUtil.isUnRecordedDateTimeStr(date) + ? '其他' + : date.replaceAll("-", "/"), // trailing: Text( // "${views[selectedViewIndex].historyRecords[index].records.length}个动漫", // style: Theme.of(context).textTheme.bodySmall, diff --git a/lib/pages/main_screen/view.dart b/lib/pages/main_screen/view.dart index 1650c1dc..4a6ca06c 100644 --- a/lib/pages/main_screen/view.dart +++ b/lib/pages/main_screen/view.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:animations/animations.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test_future/controllers/backup_service.dart'; +import 'package:flutter_test_future/controllers/theme_controller.dart'; import 'package:flutter_test_future/global.dart'; import 'package:flutter_test_future/pages/main_screen/logic.dart'; import 'package:flutter_test_future/utils/sp_profile.dart'; @@ -27,6 +28,8 @@ class _MainScreenState extends State { bool get alwaysPortrait => false; bool expandSideBar = SpProfile.getExpandSideBar(); + bool get hideMobileBottomLabel => + ThemeController.to.hideMobileBottomLabel.value; @override Widget build(BuildContext context) { @@ -211,35 +214,42 @@ class _MainScreenState extends State { mainAxisSize: MainAxisSize.min, children: [ // const CommonDivider(), - NavigationBar( - selectedIndex: logic.selectedTabIdx, - height: 60, - elevation: 0, - labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, - indicatorColor: Colors.transparent, - backgroundColor: Theme.of(context).appBarTheme.backgroundColor, - onDestinationSelected: (value) { - if (logic.searchTabIdx == value && - logic.selectedTabIdx == value) { - // 如果点击的是探索页,且当前已在探索页,则进入聚合搜索页 - logic.openSearchPage(context); - } else { - logic.toTabPage(value); - } - }, - destinations: [ - for (var tab in logic.tabs) - NavigationDestination( - icon: tab.icon, - selectedIcon: tab.selectedIcon ?? tab.icon, - label: tab.name, - ), - ]), + _buildBottomNavigationBar(), ], ), ); } + Widget _buildBottomNavigationBar() { + return Obx( + () => NavigationBar( + selectedIndex: logic.selectedTabIdx, + height: hideMobileBottomLabel ? 60 : null, + elevation: hideMobileBottomLabel ? 0 : null, + labelBehavior: hideMobileBottomLabel + ? NavigationDestinationLabelBehavior.alwaysHide + : null, + indicatorColor: hideMobileBottomLabel ? Colors.transparent : null, + backgroundColor: Theme.of(context).appBarTheme.backgroundColor, + onDestinationSelected: (value) { + if (logic.searchTabIdx == value && logic.selectedTabIdx == value) { + // 如果点击的是探索页,且当前已在探索页,则进入聚合搜索页 + logic.openSearchPage(context); + } else { + logic.toTabPage(value); + } + }, + destinations: [ + for (var tab in logic.tabs) + NavigationDestination( + icon: tab.icon, + selectedIcon: tab.selectedIcon ?? tab.icon, + label: tab.name, + ), + ]), + ); + } + _buildMainPage() { if (!enableAnimation) return logic.tabs[logic.selectedTabIdx].page; diff --git a/lib/pages/network/sources/pages/trace/view.dart b/lib/pages/network/sources/pages/trace/view.dart index dc05d414..f1342451 100644 --- a/lib/pages/network/sources/pages/trace/view.dart +++ b/lib/pages/network/sources/pages/trace/view.dart @@ -276,7 +276,7 @@ class _TracePageState extends State { maxReviewCntAnime = map['anime']; } // 最大回顾数都为1时,不进行显示 - if (maxReviewCntAnime?.reviewNumber == 1) maxReviewCntAnime = null; + if (maxReviewCnt == 1) maxReviewCntAnime = null; animeTotal = await AnimeDao.getTotal(); recordTotal = await HistoryDao.getCount(); diff --git a/lib/pages/network/update/need_update_anime_list.dart b/lib/pages/network/update/need_update_anime_list.dart index 201cdd05..6e26e1b5 100644 --- a/lib/pages/network/update/need_update_anime_list.dart +++ b/lib/pages/network/update/need_update_anime_list.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:flutter_test_future/animation/fade_animated_switcher.dart'; import 'package:flutter_test_future/components/anime_item_auto_load.dart'; +import 'package:flutter_test_future/components/common_tab_bar.dart'; +import 'package:flutter_test_future/components/loading_widget.dart'; import 'package:flutter_test_future/dao/anime_dao.dart'; import 'package:flutter_test_future/models/anime.dart'; import 'package:flutter_test_future/models/enum/play_status.dart'; import 'package:flutter_test_future/utils/time_util.dart'; -import 'package:flutter_test_future/widgets/common_scaffold_body.dart'; -import 'package:scrollview_observer/scrollview_observer.dart'; +import 'package:flutter_test_future/widgets/common_tab_bar_view.dart'; class NeedUpdateAnimeList extends StatefulWidget { const NeedUpdateAnimeList({Key? key}) : super(key: key); @@ -16,20 +16,17 @@ class NeedUpdateAnimeList extends StatefulWidget { State createState() => _NeedUpdateAnimeListState(); } -class _NeedUpdateAnimeListState extends State { +class _NeedUpdateAnimeListState extends State + with SingleTickerProviderStateMixin { List animes = []; - List filteredAnimes = []; bool loadOk = false; final allWeeklyItem = WeeklyItem(title: '全部', weekday: 0); final unknownWeeklyItem = WeeklyItem(title: '未知', weekday: -1); List weeklyItems = []; late WeeklyItem curWeeklyItem; - int get curBarItemIndex => weeklyItems.indexOf(curWeeklyItem); - final scrollController = ScrollController(); - late final observerController = - ListObserverController(controller: scrollController); + late final tabController = TabController(length: 9, vsync: this); @override void initState() { @@ -39,11 +36,11 @@ class _NeedUpdateAnimeListState extends State { @override void dispose() { - scrollController.dispose(); + tabController.dispose(); super.dispose(); } - _loadData() async { + Future _loadData() async { weeklyItems.addAll([allWeeklyItem, unknownWeeklyItem]); final now = DateTime.now(); @@ -58,13 +55,12 @@ class _NeedUpdateAnimeListState extends State { weeklyItems.add(item); if (now.weekday == dateTime.weekday) curWeeklyItem = item; } - + tabController.animateTo(weeklyItems.indexOf(curWeeklyItem)); animes = await AnimeDao.getAllNeedUpdateAnimes(includeEmptyUrl: true); _sortAnimes(); - loadOk = true; - _filterAnime(); - - observerController.initialIndex = weeklyItems.indexOf(curWeeklyItem); + setState(() { + loadOk = true; + }); } @override @@ -72,81 +68,52 @@ class _NeedUpdateAnimeListState extends State { return Scaffold( appBar: AppBar( title: Text("${animes.length} 个未完结"), - ), - body: CommonScaffoldBody( - child: FadeAnimatedSwitcher( - destWidget: Column( - children: [ - _buildWeeklyBar(), - Expanded(child: _buildAnimeCardListView()), + bottom: CommonBottomTabBar( + isScrollable: true, + tabController: tabController, + tabs: [ + for (final item in weeklyItems) + Tab( + child: Text.rich(TextSpan(children: [ + TextSpan(text: item.title), + const WidgetSpan(child: SizedBox(width: 4)), + TextSpan( + text: _filterAnime(item).length.toString(), + style: TextStyle( + fontSize: + Theme.of(context).textTheme.bodySmall?.fontSize), + ) + ]))), ], ), - loadOk: loadOk, - )), - ); - } - - Widget _buildWeeklyBar() { - return Container( - height: 40, - margin: const EdgeInsets.fromLTRB(10, 15, 10, 5), - child: ListViewObserver( - controller: observerController, - child: ListView( - controller: scrollController, - scrollDirection: Axis.horizontal, - children: [for (var item in weeklyItems) _buildWeeklyItem(item)]), ), - ); - } - - _buildWeeklyItem(WeeklyItem item) { - bool isCur = curWeeklyItem == item; - - return Container( - margin: const EdgeInsets.symmetric(horizontal: 5), - child: ChoiceChip( - label: Text(item.title), - selected: isCur, - showCheckmark: false, - onSelected: (value) { - if (!value) return; - setState(() { - curWeeklyItem = item; - _filterAnime(); - }); - observerController.animateTo( - index: curBarItemIndex, - duration: const Duration(milliseconds: 200), - curve: Curves.linear, - offset: (_) { - return MediaQuery.of(context).size.width * 0.5; - }, - ); - }, + body: CommonTabBarView( + controller: tabController, + children: [ + for (final item in weeklyItems) + loadOk + ? _buildAnimeCardListView(_filterAnime(item)) + : const LoadingWidget(), + ], ), ); } - Widget _buildAnimeCardListView() { - if (filteredAnimes.isEmpty) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 20), - child: Text('什么都没有~'), - ); + Widget _buildAnimeCardListView(List animes) { + if (animes.isEmpty) { + return const Center(child: Text('什么都没有~')); } return GridView.builder( - key: ObjectKey(curWeeklyItem), - itemCount: filteredAnimes.length, - itemBuilder: (context, index) => _buildAnimeItem(index), + itemCount: animes.length, + itemBuilder: (context, index) => _buildAnimeItem(animes[index]), gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( mainAxisExtent: 140, maxCrossAxisExtent: 520), ); } - AnimeItemAutoLoad _buildAnimeItem(int index) { + AnimeItemAutoLoad _buildAnimeItem(Anime anime) { return AnimeItemAutoLoad( - anime: filteredAnimes[index], + anime: anime, showProgress: false, showReviewNumber: false, showWeekday: true, @@ -174,8 +141,10 @@ class _NeedUpdateAnimeListState extends State { } /// 筛选动漫 - void _filterAnime() { - int weekday = curWeeklyItem.weekday; + List _filterAnime(WeeklyItem weeklyItem) { + final weekday = weeklyItem.weekday; + List filteredAnimes = []; + if (weekday == allWeeklyItem.weekday) { filteredAnimes = animes; } else if (weekday == unknownWeeklyItem.weekday) { @@ -186,8 +155,7 @@ class _NeedUpdateAnimeListState extends State { .where((anime) => anime.premiereDateTime?.weekday == weekday) .toList(); } - - if (mounted) setState(() {}); + return filteredAnimes; } } diff --git a/lib/pages/settings/about_version.dart b/lib/pages/settings/about_version.dart index e3396dee..babf3370 100644 --- a/lib/pages/settings/about_version.dart +++ b/lib/pages/settings/about_version.dart @@ -1,12 +1,12 @@ import 'package:eva_icons_flutter/eva_icons_flutter.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_test_future/components/logo.dart'; import 'package:flutter_test_future/controllers/app_upgrade_controller.dart'; import 'package:flutter_test_future/models/enum/load_status.dart'; import 'package:flutter_test_future/pages/changelog/view.dart'; import 'package:flutter_test_future/utils/launch_uri_util.dart'; import 'package:flutter_test_future/values/assets.dart'; import 'package:flutter_test_future/widgets/common_scaffold_body.dart'; +import 'package:flutter_test_future/widgets/rotated_logo.dart'; import 'package:flutter_test_future/widgets/svg_asset_icon.dart'; import 'package:get/get.dart'; @@ -35,7 +35,10 @@ class _AboutVersionState extends State { children: [ Column( children: [ - const Logo(), + const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: RotatedLogo(size: 72), + ), Text("当前版本: ${AppUpgradeController.to.curVersion}"), _buildWebsiteIconsRow(context), ], diff --git a/lib/pages/settings/general_setting.dart b/lib/pages/settings/general_setting.dart index 9da338a0..357d1a73 100644 --- a/lib/pages/settings/general_setting.dart +++ b/lib/pages/settings/general_setting.dart @@ -1,7 +1,10 @@ + import 'package:flutter/material.dart'; import 'package:flutter_test_future/controllers/theme_controller.dart'; import 'package:flutter_test_future/models/page_switch_animation.dart'; +import 'package:flutter_test_future/pages/settings/widgets/main_tab_layout_setting.dart'; import 'package:flutter_test_future/utils/platform.dart'; +import 'package:flutter_test_future/utils/settings.dart'; import 'package:flutter_test_future/utils/sp_profile.dart'; import 'package:flutter_test_future/utils/sp_util.dart'; import 'package:flutter_test_future/utils/time_util.dart'; @@ -60,12 +63,19 @@ class _GeneralSettingPageState extends State { if (PlatformUtil.isMobile) ListTile( title: const Text("选择页面切换动画"), - subtitle: - Text(ThemeController.to.pageSwitchAnimation.value.title), + subtitle: Obx(() => + Text(ThemeController.to.pageSwitchAnimation.value.title)), onTap: () { _showDialogSelectPageSwitchAnimation(context); }, ), + ListTile( + title: const Text('调整选项卡'), + subtitle: const Text('启用或禁用选项卡'), + onTap: () { + _showDialogConfigureMainTab(); + }, + ), ListTile( title: const Text('重置移动清单对话框提示'), subtitle: const Text("完成最后一集时会提示移动清单"), @@ -76,6 +86,18 @@ class _GeneralSettingPageState extends State { ToastUtil.showText("重置成功"); }, ), + if (PlatformUtil.isMobile) + Obx( + () => SwitchListTile( + title: const Text('隐藏底部栏文字'), + value: ThemeController.to.hideMobileBottomLabel.value, + onChanged: (value) { + ThemeController.to.hideMobileBottomLabel.value = value; + SettingsUtil.setValue( + SettingsEnum.hideMobileBottomLabel, value); + }, + ), + ), ], ), SettingCard( @@ -117,6 +139,16 @@ class _GeneralSettingPageState extends State { ); } + void _showDialogConfigureMainTab() { + showDialog( + context: context, + builder: (context) => const AlertDialog( + title: Text('调整选项卡'), + content: MainTabLayoutSettingPage(), + ), + ); + } + Future _showDialogSelectPageSwitchAnimation(BuildContext context) { ThemeController themeController = Get.find(); diff --git a/lib/pages/settings/theme_page.dart b/lib/pages/settings/theme_page.dart index ecdf3ed2..beb1c816 100644 --- a/lib/pages/settings/theme_page.dart +++ b/lib/pages/settings/theme_page.dart @@ -1,10 +1,11 @@ import 'package:flex_color_picker/flex_color_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test_future/controllers/theme_controller.dart'; -import 'package:flutter_test_future/pages/main_screen/logic.dart'; import 'package:flutter_test_future/values/values.dart'; import 'package:flutter_test_future/widgets/common_scaffold_body.dart'; +import 'package:flutter_test_future/widgets/responsive.dart'; import 'package:flutter_test_future/widgets/setting_card.dart'; +import 'package:get/get.dart'; class ThemePage extends StatefulWidget { const ThemePage({super.key}); @@ -25,68 +26,114 @@ class _ThemePageState extends State { padding: const EdgeInsets.only(bottom: 50), children: [ SettingCard( - title: '选项卡', + title: '主题', children: [ ListTile( - title: const Text('调整选项卡'), - subtitle: const Text('启用或禁用选项卡'), - onTap: () { - _showDialogConfigureMainTab(); - }, - ), - ], - ), - SettingCard( - title: '主题配色', - trailing: TextButton( - onPressed: () { - themeController.resetCustomPrimaryColor(); - }, - child: const Text('重置')), - children: [ - ListTile( - title: const Text('选择主题色'), - trailing: _buildColorIndicator(), + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('选择主题色'), + SizedBox(width: 8), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: themeController.resetCustomPrimaryColor, + child: const Text('重置'), + ), + _buildColorIndicator(), + ], + ), onTap: _showColorPicker, ), + if (!Responsive.isMobile(context)) ...[ + ListTile( + title: const Text('主题模式'), + trailing: _buildThemeSelector(), + ), + ListTile( + title: const Text('夜间主题'), + trailing: _buildColorSelector(), + ), + ] ], ), - _buildThemeMode(), - _buildDarkTheme(), + if (Responsive.isMobile(context)) ...[ + _buildThemeMode(), + _buildDarkTheme(), + ] ], )), ); } + Widget _buildColorSelector() { + return Obx(() => SegmentedButton( + segments: [ + for (final themeColor in AppTheme.darkColors) + ButtonSegment( + icon: Icon(Icons.circle, color: themeColor.representativeColor), + value: themeColor, + label: Text(themeColor.name), + ), + ], + // showSelectedIcon: false, + emptySelectionAllowed: true, + selected: {ThemeController.to.darkThemeColor.value}, + onSelectionChanged: (value) { + if (value.isEmpty) return; + final themeColor = value.first; + ThemeController.to.changeTheme(themeColor.key, dark: true); + }, + )); + } + + Widget _buildThemeSelector() { + return Obx(() => SegmentedButton( + segments: [ + for (int i = 0; i < AppTheme.darkModes.length; i++) + ButtonSegment( + icon: Icon(AppTheme.darkModeIcons[i]), + value: i, + label: Text(AppTheme.darkModes[i]), + ), + ], + // showSelectedIcon: false, + emptySelectionAllowed: true, + selected: {ThemeController.to.themeModeIdx.value}, + onSelectionChanged: (value) { + if (value.isEmpty) return; + final selectedIndex = value.first; + ThemeController.to.setThemeMode(selectedIndex); + }, + )); + } + SettingCard _buildThemeMode() { return SettingCard( title: '主题模式', useCard: false, children: [ - _buildRadioGrid( - children: [ - for (int i = 0; i < AppTheme.darkModes.length; ++i) - _buildThemeModeItem(context, i) - ], - ) + Container( + margin: const EdgeInsets.only(top: 8, left: 16), + child: _buildThemeSelector(), + ), ], ); } - Widget _buildThemeModeItem(BuildContext context, int themeModeIndex) { - final selected = ThemeController.to.themeModeIdx.value == themeModeIndex; - final fg = selected ? Theme.of(context).primaryColor : null; - - return _buildRadioItem( - icon: Icon(AppTheme.darkModeIcons[themeModeIndex], color: fg), - label: AppTheme.darkModes[themeModeIndex], - selected: ThemeController.to.themeModeIdx.value == themeModeIndex, - onTap: () { - setState(() { - ThemeController.to.themeModeIdx.value = themeModeIndex; - }); - ThemeController.to.setThemeMode(themeModeIndex); - }, + Widget _buildDarkTheme() { + return SettingCard( + title: '夜间主题', + useCard: false, + children: [ + Container( + margin: const EdgeInsets.only(top: 8, left: 16), + child: _buildColorSelector(), + ), + ], ); } @@ -123,162 +170,18 @@ class _ThemePageState extends State { themeController.changeCustomPrimaryColor(newColor); } - ColorIndicator _buildColorIndicator() { - return ColorIndicator( - width: 32, - height: 32, + Widget _buildColorIndicator() { + return Obx(() => ColorIndicator( + width: 24, + height: 24, borderRadius: 99, color: _getCurPrimaryColor(), elevation: 1, - onSelectFocus: false); + onSelectFocus: false)); } Color _getCurPrimaryColor() { return themeController.customPrimaryColor.value ?? Theme.of(context).primaryColor; } - - void _showDialogConfigureMainTab() { - showDialog( - context: context, - builder: (context) => const AlertDialog( - title: Text('调整选项卡'), - content: MainTabLayoutSettingPage(), - ), - ); - } - - Widget _buildDarkTheme() { - return SettingCard(title: '夜间主题', useCard: false, children: [ - _buildRadioGrid( - children: [ - for (int i = 0; i < AppTheme.darkColors.length; ++i) - _buildColorAtlasItem(AppTheme.darkColors[i], dark: true) - ], - ), - ]); - } - - Widget _buildColorAtlasItem(ThemeColor themeColor, {bool dark = false}) { - final selectedThemeColor = dark - ? ThemeController.to.darkThemeColor.value - : ThemeController.to.lightThemeColor.value; - - return _buildRadioItem( - icon: Icon(Icons.circle, color: themeColor.representativeColor), - label: themeColor.name, - selected: selectedThemeColor == themeColor, - onTap: () { - ThemeController.to.changeTheme(themeColor.key, dark: dark); - }, - ); - } - - Widget _buildRadioGrid({required List children}) { - return Container( - height: 100, - margin: const EdgeInsets.only(top: 5), - child: GridView( - padding: const EdgeInsets.symmetric(horizontal: 10), - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - mainAxisExtent: 100, - maxCrossAxisExtent: MediaQuery.of(context).size.width / 3, - ), - children: children, - ), - ); - } - - Widget _buildRadioItem({ - Widget? icon, - String? label, - void Function()? onTap, - bool selected = false, - }) { - return Container( - margin: const EdgeInsets.symmetric(horizontal: 5, vertical: 10), - decoration: BoxDecoration( - border: Border.all( - color: selected - ? Theme.of(context).primaryColor - : Theme.of(context).dividerColor, - ), - borderRadius: BorderRadius.circular(AppTheme.cardRadius), - ), - child: InkWell( - borderRadius: BorderRadius.circular(AppTheme.cardRadius), - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (icon != null) icon, - const SizedBox(height: 5), - Text( - label ?? '', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: selected ? Theme.of(context).primaryColor : null), - ), - ], - ), - ), - ), - ); - } -} - -class MainTabLayoutSettingPage extends StatefulWidget { - const MainTabLayoutSettingPage({super.key}); - - @override - State createState() => - _MainTabLayoutSettingPageState(); -} - -class _MainTabLayoutSettingPageState extends State { - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - child: Column( - children: MainScreenLogic.to.allTabs - .map((tab) => ListTile( - iconColor: Theme.of(context).iconTheme.color, - leading: tab.icon, - title: _buildTitle(tab, context), - dense: true, - trailing: tab.canHide ? _buildTurnShowIcon(tab) : null, - )) - .toList(), - ), - ); - } - - IconButton _buildTurnShowIcon(MainTab tab) { - return IconButton( - icon: Icon(tab.show ? Icons.remove : Icons.add_circle_outline), - onPressed: () async { - bool? show = tab.turnShow?.call(); - if (show == null) return; - - tab.show = show; - MainScreenLogic.to.loadTabs(); - setState(() {}); - }, - ); - } - - Text _buildTitle(MainTab tab, BuildContext context) { - return Text( - tab.name, - style: tab.show - ? null - : TextStyle( - decoration: TextDecoration.lineThrough, - color: Theme.of(context).hintColor, - ), - ); - } } diff --git a/lib/pages/settings/widgets/main_tab_layout_setting.dart b/lib/pages/settings/widgets/main_tab_layout_setting.dart new file mode 100644 index 00000000..3219b90d --- /dev/null +++ b/lib/pages/settings/widgets/main_tab_layout_setting.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test_future/pages/main_screen/logic.dart'; + +class MainTabLayoutSettingPage extends StatefulWidget { + const MainTabLayoutSettingPage({super.key}); + + @override + State createState() => + _MainTabLayoutSettingPageState(); +} + +class _MainTabLayoutSettingPageState extends State { + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + children: MainScreenLogic.to.allTabs + .map((tab) => ListTile( + iconColor: Theme.of(context).iconTheme.color, + leading: tab.icon, + title: _buildTitle(tab, context), + dense: true, + trailing: tab.canHide ? _buildTurnShowIcon(tab) : null, + )) + .toList(), + ), + ); + } + + IconButton _buildTurnShowIcon(MainTab tab) { + return IconButton( + icon: Icon(tab.show ? Icons.remove : Icons.add_circle_outline), + onPressed: () async { + bool? show = tab.turnShow?.call(); + if (show == null) return; + + tab.show = show; + MainScreenLogic.to.loadTabs(); + setState(() {}); + }, + ); + } + + Text _buildTitle(MainTab tab, BuildContext context) { + return Text( + tab.name, + style: tab.show + ? null + : TextStyle( + decoration: TextDecoration.lineThrough, + color: Theme.of(context).hintColor, + ), + ); + } +} diff --git a/lib/repositories/bangumi_repository.dart b/lib/repositories/bangumi_repository.dart new file mode 100644 index 00000000..e8dc0571 --- /dev/null +++ b/lib/repositories/bangumi_repository.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test_future/models/bangumi/bangumi.dart'; +import 'package:flutter_test_future/models/params/result.dart'; +import 'package:flutter_test_future/utils/dio_util.dart'; +import 'package:flutter_test_future/utils/network/bangumi_api.dart'; + +class BangumiRepository { + Future> fetchSubjectCharacters( + String subjectId) async { + final result = await DioUtil.get(BangumiApi.subjectCharacters(subjectId), + headers: BangumiApi.headers); + return result.toModelList( + transform: RelatedCharacter.fromMap, + dataType: ResultDataType.responseBody, + ); + } + + Future> fetchSubjectPersons(String subjectId) async { + final result = await DioUtil.get(BangumiApi.subjectPersons(subjectId), + headers: BangumiApi.headers); + return result.toModelList( + transform: RelatedPerson.fromMap, + dataType: ResultDataType.responseBody, + ); + } +} diff --git a/lib/utils/climb/climb.dart b/lib/utils/climb/climb.dart index bf2a1b91..163773dc 100644 --- a/lib/utils/climb/climb.dart +++ b/lib/utils/climb/climb.dart @@ -85,11 +85,19 @@ mixin Climb { } /// 统一解析 - Future dioGetAndParse(String url, - {bool isMobile = false, String? foreignSourceName}) async { + Future dioGetAndParse( + String url, { + bool isMobile = false, + String? foreignSourceName, + Map? headers, + }) async { String sourceName = foreignSourceName ?? this.sourceName; - Result result = await DioUtil.get(url, isMobile: isMobile); + Result result = await DioUtil.get( + url, + isMobile: isMobile, + headers: headers, + ); if (result.code != 200) { ToastUtil.showText("$sourceName:${result.msg}"); return null; diff --git a/lib/utils/climb/climb_anime_util.dart b/lib/utils/climb/climb_anime_util.dart index 99e59c1f..ecb2764e 100644 --- a/lib/utils/climb/climb_anime_util.dart +++ b/lib/utils/climb/climb_anime_util.dart @@ -46,7 +46,8 @@ class ClimbAnimeUtil { String keyword, ClimbWebsite climbWebStie) async { List climbAnimes = []; try { - climbAnimes = await climbWebStie.climb.searchAnimeByKeyword(keyword); + climbAnimes = await climbWebStie.climb + .searchAnimeByKeyword(Uri.encodeComponent(keyword)); } catch (e) { e.printError(); } diff --git a/lib/utils/climb/climb_bangumi.dart b/lib/utils/climb/climb_bangumi.dart index c71c197e..9f88b35a 100644 --- a/lib/utils/climb/climb_bangumi.dart +++ b/lib/utils/climb/climb_bangumi.dart @@ -1,13 +1,11 @@ -import 'dart:io'; - import 'package:darty_json/darty_json.dart'; -import 'package:flutter_test_future/controllers/app_upgrade_controller.dart'; import 'package:flutter_test_future/models/anime.dart'; import 'package:flutter_test_future/models/week_record.dart'; import 'package:flutter_test_future/utils/climb/climb.dart'; import 'package:flutter_test_future/utils/climb/site_collection_tab.dart'; import 'package:flutter_test_future/utils/climb/user_collection.dart'; import 'package:flutter_test_future/utils/dio_util.dart'; +import 'package:flutter_test_future/utils/network/bangumi_api.dart'; import 'package:flutter_test_future/values/values.dart'; import 'package:html/dom.dart'; @@ -48,10 +46,8 @@ class ClimbBangumi with Climb { "/subject_search/$keyword?cat=${Config.selectedBangumiSearchCategoryKey}"; List climbAnimes = []; - var document = await dioGetAndParse(url); - if (document == null) { - return []; - } + final document = await dioGetAndParse(url, headers: BangumiApi.headers); + if (document == null) return []; climbAnimes = parseAnimeListByBrowserItemList(document); return climbAnimes; @@ -237,8 +233,8 @@ class ClimbBangumi with Climb { @override Future>> climbWeeklyTable() async { final resp = await DioUtil.get( - 'https://api.bgm.tv/calendar', - headers: _apiHeaders, + BangumiApi.calendar, + headers: BangumiApi.headers, ); if (resp.isFailure || resp.data.data is! List) return []; @@ -266,11 +262,4 @@ class ClimbBangumi with Climb { } return weeks; } - - Map get _apiHeaders { - return { - 'user-agent': - 'linyi102/anime_trace/${AppUpgradeController.to.curVersion} (${Platform.operatingSystem}) (https://github.com/linyi102/anime_trace)', - }; - } } diff --git a/lib/utils/network/bangumi_api.dart b/lib/utils/network/bangumi_api.dart new file mode 100644 index 00000000..21c4cecf --- /dev/null +++ b/lib/utils/network/bangumi_api.dart @@ -0,0 +1,19 @@ +import 'dart:io'; + +import 'package:flutter_test_future/controllers/app_upgrade_controller.dart'; + +class BangumiApi { + static Map headers = { + 'user-agent': + 'linyi102/anime_trace/${AppUpgradeController.to.curVersion} (${Platform.operatingSystem}) (https://github.com/linyi102/anime_trace)', + 'Cookie': 'chii_searchDateLine=1729417788', + }; + + static String baseUrl = 'https://api.bgm.tv'; + + static String calendar = '$baseUrl/calendar'; + static String subjectCharacters(String subjectId) => + '$baseUrl/v0/subjects/$subjectId/characters'; + static String subjectPersons(String subjectId) => + '$baseUrl/v0/subjects/$subjectId/persons'; +} diff --git a/lib/utils/settings.dart b/lib/utils/settings.dart new file mode 100644 index 00000000..144c0b9c --- /dev/null +++ b/lib/utils/settings.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test_future/utils/sp_util.dart'; + +enum SettingsEnum { + hideMobileBottomLabel('hideMobileBottomNavigationBarLabel', false); + + final String key; + final T defaultValue; + const SettingsEnum(this.key, this.defaultValue); +} + +class SettingsUtil { + static T getValue(SettingsEnum setting) { + if (T == bool) { + return SPUtil.getBool(setting.key, + defaultValue: setting.defaultValue as bool) as T; + } else if (T == int) { + return SPUtil.getInt(setting.key, + defaultValue: setting.defaultValue as int) as T; + } else if (T == double) { + return SPUtil.getDouble(setting.key, + defaultValue: setting.defaultValue as double) as T; + } else if (T == String) { + return SPUtil.getString(setting.key, + defaultValue: setting.defaultValue as String) as T; + } else { + throw Exception('暂不支持该类型:${T.runtimeType}'); + } + } + + static Future setValue(SettingsEnum setting, T value) { + if (T == bool) { + return SPUtil.setBool(setting.key, value as bool); + } else if (T == int) { + return SPUtil.setInt(setting.key, value as int); + } else if (T == double) { + return SPUtil.setDouble(setting.key, value as double); + } else if (T == String) { + return SPUtil.setString(setting.key, value as String); + } else { + throw Exception('暂不支持该类型:${T.runtimeType}'); + } + } +} diff --git a/lib/utils/sqlite_util.dart b/lib/utils/sqlite_util.dart index 14bd2049..d8c8141c 100644 --- a/lib/utils/sqlite_util.dart +++ b/lib/utils/sqlite_util.dart @@ -752,7 +752,14 @@ class SqliteUtil { return firstIntValue(rows); } - static Future firstIntValue(List> rows) async { + static int firstIntValue(List> rows) { + if (rows.isEmpty) return 0; return rows.first.values.firstWhere((element) => element is int) as int; } + + static T? firstRowColumnValue(List> rows) { + if (rows.isEmpty || rows.first.values.isEmpty) return null; + final value = rows.first.values.first; + return value is T ? value : null; + } } diff --git a/lib/utils/string.dart b/lib/utils/string.dart new file mode 100644 index 00000000..a5507fe4 --- /dev/null +++ b/lib/utils/string.dart @@ -0,0 +1,7 @@ +extension StringExtension on String? { + bool get isNull => this == null; + + bool get isBlank => !isNull && this!.trim().isEmpty; + + bool get isNullOrBlank => isNull || isBlank; +} diff --git a/lib/utils/time_util.dart b/lib/utils/time_util.dart index 1db944b0..b8112ade 100644 --- a/lib/utils/time_util.dart +++ b/lib/utils/time_util.dart @@ -3,6 +3,10 @@ import 'package:flutter_test_future/utils/sp_util.dart'; import 'package:get_time_ago/get_time_ago.dart'; class TimeUtil { + static final unRecordedDateTime = DateTime(0); + + static isUnRecordedDateTimeStr(String str) => str.startsWith('0000'); + /// 根据秒数转为时长字符串 static String getReadableDuration(Duration duration) { String res = ""; @@ -43,6 +47,17 @@ class TimeUtil { return DateTime.now().toString().substring(0, 19); } + /// 展示年月日 + static String getYMD(String str) { + final dateTime = DateTime.tryParse(str); + if (dateTime == null) return ''; + return [ + dateTime.year, + dateTime.month.toString().padLeft(2, '0'), + dateTime.day.toString().padLeft(2, '0'), + ].join('-'); + } + // 显示年月日时分 static String getHumanReadableDateTimeStr( String time, { @@ -53,8 +68,10 @@ class TimeUtil { bool removeLeadingZero = false, }) { if (time.isEmpty) return ""; + DateTime? dateTime = DateTime.tryParse(time); + if (dateTime == null) return ""; + if (dateTime == unRecordedDateTime) return ""; - DateTime dateTime = DateTime.parse(time); String dateTimeStr = dateTime.toString(); DateTime now = DateTime.now(); // 0123456789 16 diff --git a/lib/values/sp_key.dart b/lib/values/sp_key.dart index 4e93c975..b0e90369 100644 --- a/lib/values/sp_key.dart +++ b/lib/values/sp_key.dart @@ -39,6 +39,9 @@ class SPKey { // 开启热键恢复最新备份文件 static get enableRestoreLatestHotkey => "enableRestoreLatestHotkey"; + + /// 手机底部导航栏隐藏文字 + static get hideMobileBottomBarLabel => "hideMobileBottomLabel"; } class Config { diff --git a/lib/values/theme.dart b/lib/values/theme.dart index ef6801e1..c448441e 100644 --- a/lib/values/theme.dart +++ b/lib/values/theme.dart @@ -57,9 +57,9 @@ class AppTheme { MingCuteIcons.mgc_sun_2_fill, MingCuteIcons.mgc_partly_cloud_night_fill, ]; - static List darkModes = ["跟随系统", "日间", "夜间"]; + static List darkModes = ["系统", "白天", "夜间"]; - static Color get blueInLight => Colors.blue; + static Color get blueInLight => const Color(0xFF1976D2); // 可选:70, 133, 243 | 61, 129, 228 static Color get blueInDark => const Color.fromRGBO(70, 133, 243, 1); diff --git a/lib/widgets/animation/fade_in_up.dart b/lib/widgets/animation/fade_in_up.dart new file mode 100644 index 00000000..12676a19 --- /dev/null +++ b/lib/widgets/animation/fade_in_up.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +class FadeInUp extends StatefulWidget { + const FadeInUp({ + super.key, + required this.child, + this.duration = const Duration(milliseconds: 200), + this.curve = Curves.ease, + this.animate = true, + }); + final Widget child; + final Duration duration; + final Curve curve; + final bool animate; + + @override + State createState() => _FadeInUpState(); +} + +class _FadeInUpState extends State + with SingleTickerProviderStateMixin { + late AnimationController controller; + late Animation offsetDy; + late Animation opacity; + + bool display = true; + + @override + void initState() { + super.initState(); + controller = AnimationController(vsync: this, duration: widget.duration); + offsetDy = Tween(begin: 20, end: 0) + .animate(CurvedAnimation(parent: controller, curve: widget.curve)); + opacity = Tween(begin: 0, end: 1) + .animate(CurvedAnimation(parent: controller, curve: widget.curve)); + controller.addStatusListener((status) { + switch (status) { + case AnimationStatus.forward: + if (mounted) { + setState(() { + display = true; + }); + } + break; + case AnimationStatus.dismissed: + if (mounted) { + setState(() { + display = false; + }); + } + break; + default: + } + }); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + widget.animate ? controller.forward() : controller.reverse(); + if (!display) return const SizedBox.shrink(); + + return AnimatedBuilder( + animation: offsetDy, + builder: (context, child) => Transform.translate( + offset: Offset(0, offsetDy.value), + child: Opacity( + opacity: opacity.value, + child: child, + ), + ), + child: widget.child, + ); + } +} diff --git a/lib/widgets/button/border_button.dart b/lib/widgets/button/border_button.dart new file mode 100644 index 00000000..545a83a3 --- /dev/null +++ b/lib/widgets/button/border_button.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +class BorderButton extends StatelessWidget { + const BorderButton({ + super.key, + this.selected = false, + required this.onTap, + required this.child, + }); + final bool selected; + final GestureTapCallback onTap; + final Widget child; + + @override + Widget build(BuildContext context) { + final radius = BorderRadius.circular(6); + + return ClipRRect( + borderRadius: radius, + child: Material( + color: selected + ? Theme.of(context).primaryColor.withOpacity(0.2) + : Theme.of(context).cardColor, + child: Ink( + child: InkWell( + onTap: onTap, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + width: 1, + color: selected + ? Theme.of(context).primaryColor + : Theme.of(context).hintColor.withOpacity(0.1)), + borderRadius: radius, + ), + child: child, + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/floating_bottom_actions.dart b/lib/widgets/floating_bottom_actions.dart new file mode 100644 index 00000000..9daf385b --- /dev/null +++ b/lib/widgets/floating_bottom_actions.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test_future/widgets/animation/fade_in_up.dart'; + +class FloatingBottomActions extends StatelessWidget { + const FloatingBottomActions({ + super.key, + required this.children, + this.itemPadding = const EdgeInsets.symmetric(horizontal: 8), + this.display = true, + }); + final List children; + final EdgeInsets itemPadding; + final bool display; + + @override + Widget build(BuildContext context) { + return FadeInUp( + animate: display, + child: Container( + alignment: Alignment.bottomCenter, + child: Card( + elevation: 2, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(20))), + clipBehavior: Clip.antiAlias, + margin: const EdgeInsets.symmetric(vertical: 16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + for (final child in children) + Padding(padding: itemPadding, child: child) + ]), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/rotated_logo.dart b/lib/widgets/rotated_logo.dart new file mode 100644 index 00000000..a26556b6 --- /dev/null +++ b/lib/widgets/rotated_logo.dart @@ -0,0 +1,126 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +class RotatedLogo extends StatefulWidget { + const RotatedLogo({super.key, this.size = 64}); + final double size; + + @override + State createState() => _RotatedLogoState(); +} + +class _RotatedLogoState extends State + with SingleTickerProviderStateMixin { + late final animationController = AnimationController( + vsync: this, + duration: const Duration(seconds: 30), + ); + + @override + void initState() { + super.initState(); + animationController.repeat(); + } + + @override + void dispose() { + animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(widget.size * 0.24), + ), + child: RotationTransition( + turns: animationController, + child: SizedBox.square( + dimension: widget.size, + child: Transform.rotate( + angle: pi / 3, + // RepaintBoundary可以解决shouldRepaint为false时仍然一直paint的问题 + child: const RepaintBoundary( + child: CustomPaint( + painter: _LogoPainter(centerDot: true), + ), + ), + ), + ), + ), + ); + } +} + +class _LogoPainter extends CustomPainter { + final bool centerDot; + const _LogoPainter({this.centerDot = true}); + + @override + void paint(Canvas canvas, Size size) { + final radius = size.width * 0.5; + final centerOffset = Offset(size.width * 0.5, size.height * 0.5); + canvas.drawCircle( + centerOffset, + radius, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = size.width * 0.03 + ..color = const Color(0xFF474E55), + ); + + if (centerDot) { + canvas.drawPoints( + PointMode.points, + [ + centerOffset, + ], + Paint() + ..strokeCap = StrokeCap.round + ..strokeWidth = size.width * 0.3 + ..color = const Color(0xFF474E55), + ); + } + + final point = Paint() + ..strokeCap = StrokeCap.round + ..strokeWidth = size.width * 0.2; + List colors = [Colors.red, Colors.blue, Colors.green]; + List offsets = calculatePointsOnCircle( + size.width * 0.5, size.height * 0.5, radius, colors.length); + for (int index = 0; index < colors.length; index++) { + canvas.drawPoints( + PointMode.points, + [ + offsets[index], + ], + point..color = colors[index], + ); + } + } + + @override + bool shouldRepaint(_LogoPainter oldDelegate) => false; + + @override + bool shouldRebuildSemantics(_LogoPainter oldDelegate) => false; + + List calculatePointsOnCircle(double x, double y, double r, int n) { + List points = []; + final theta = (2 * pi) / n; + for (int i = 0; i < n; i++) { + final cx = r * cos(i * theta); + final cy = r * sin(i * theta); + final pointX = x + cx; + final pointY = y + cy; + points.add(Offset(pointX, pointY)); + } + points.sort((a, b) => a.dy < b.dy ? 1 : -1); + return points; + } +} diff --git a/pubspec.lock b/pubspec.lock index 36513387..3afe6d83 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -341,14 +341,6 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "4.0.1" - flutter_screenutil: - dependency: "direct main" - description: - name: flutter_screenutil - sha256: "8239210dd68bee6b0577aa4a090890342d04a136ce1c81f98ee513fc0ce891de" - url: "https://pub.flutter-io.cn" - source: hosted - version: "5.9.3" flutter_svg: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index d6ec6122..80576c34 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,7 +50,6 @@ dependencies: media_kit_libs_windows_video: ^1.0.9 media_kit_native_event_loop: ^1.0.8 desktop_drop: ^0.4.4 # 拖动文件到应用 - flutter_screenutil: ^5.8.4 flutter_svg: ^2.0.7 animations: ^2.0.7 app_installer: ^1.1.0 diff --git a/test/date_time_test.dart b/test/date_time_test.dart new file mode 100644 index 00000000..d0a6f9c6 --- /dev/null +++ b/test/date_time_test.dart @@ -0,0 +1,7 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Special DateTime', () { + expect(DateTime(0) == DateTime(0), true); + }); +} diff --git a/test/uri_encode_test.dart b/test/uri_encode_test.dart new file mode 100644 index 00000000..ada3910a --- /dev/null +++ b/test/uri_encode_test.dart @@ -0,0 +1,33 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('URI Encode', () async { + const text = '乱马1/2'; + expect(Uri.encodeFull(text), '%E4%B9%B1%E9%A9%AC1/2'); + expect(Uri.encodeComponent(text), '%E4%B9%B1%E9%A9%AC1%2F2'); + expect(Uri.encodeQueryComponent(text), '%E4%B9%B1%E9%A9%AC1%2F2'); + }); + + test('http', () async { + var headers = {'Cookie': 'chii_searchDateLine=1729417788'}; + var dio = Dio(); + var response = await dio.get( + // 'https://api.bgm.tv/search/subject/乱马1%2F2', + // 'https://bangumi.tv/subject_search/乱马1%2F2', + 'https://bangumi.tv/subject_search/${Uri.encodeComponent('乱马1/2')}', + options: Options( + headers: headers, + ), + ); + + if (response.statusCode == 200) { + debugPrint(json.encode(response.data)); + } else { + debugPrint(response.statusMessage); + } + }); +}