From 20113dcc5ad0140a412b703e81bfae80e8611467 Mon Sep 17 00:00:00 2001 From: iota9star Date: Sun, 4 Jun 2023 00:26:48 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=96=20v1.1.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-apps.yml | 32 ++- lib/internal/hive.dart | 27 +- lib/internal/http.dart | 10 +- lib/internal/resolver.dart | 34 +++ lib/mikan_route.dart | 13 +- lib/mikan_routes.dart | 22 ++ lib/model/announcement.dart | 67 +++++ lib/model/announcement.g.dart | 81 ++++++ lib/model/bangumi_details.dart | 2 +- lib/model/index.dart | 5 + lib/model/index.g.dart | 7 +- lib/model/record_details.dart | 38 +-- lib/model/record_item.dart | 36 +-- lib/providers/bangumi_model.dart | 2 +- lib/providers/index_model.dart | 6 + lib/providers/record_detail_model.dart | 2 +- lib/providers/subgroup_model.dart | 2 +- lib/ui/fragments/bangumi_sliver_grid.dart | 207 ++++++++------ lib/ui/fragments/card_ratio.dart | 140 ++-------- lib/ui/fragments/card_style.dart | 308 ++------------------- lib/ui/fragments/card_width.dart | 49 ++++ lib/ui/fragments/index.dart | 9 +- lib/ui/fragments/settings.dart | 61 ++++- lib/ui/fragments/subscribed.dart | 316 +++++++++++++--------- lib/ui/fragments/theme_color.dart | 1 - lib/ui/pages/announcement.dart | 131 +++++++++ lib/ui/pages/bangumi.dart | 13 +- lib/ui/pages/fonts.dart | 4 +- lib/ui/pages/record.dart | 5 +- lib/widget/bottom_sheet.dart | 29 +- lib/widget/placeholder_text.dart | 2 +- lib/widget/sliver_pinned_header.dart | 21 +- pubspec.yaml | 3 +- 33 files changed, 967 insertions(+), 718 deletions(-) create mode 100644 lib/model/announcement.dart create mode 100644 lib/model/announcement.g.dart create mode 100644 lib/ui/fragments/card_width.dart create mode 100644 lib/ui/pages/announcement.dart diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml index 67e5b21..c2d937f 100644 --- a/.github/workflows/build-apps.yml +++ b/.github/workflows/build-apps.yml @@ -102,16 +102,6 @@ jobs: channel: stable - name: Set up xcode uses: devbotsxyz/xcode-select@v1 - - name: Build iOS - run: | - cp -f pubspec.yaml assets/ - flutter pub get - flutter build ios --release --tree-shake-icons - mkdir -p Payload - mv build/ios/iphoneos/Runner.app Payload - /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)" - brew install zip - zip -r ios-release.ipa Payload - name: Build macos env: MACOS_APP_RELEASE_PATH: build/macos/Build/Products/Release @@ -136,11 +126,27 @@ jobs: "mikan.app" cd ../../../../../ mv $MACOS_APP_RELEASE_PATH/MikanProject.dmg macos-release.dmg - - name: Release build + - name: Release Mac uses: ncipollo/release-action@v1 with: allowUpdates: true - artifacts: "macos-release.dmg,ios-release.ipa" + artifacts: "macos-release.dmg" + token: ${{ secrets.ACTION_TOKEN }} + tag: ${{ github.event.inputs.TAG }} + - name: Build iOS + run: | + cp -f pubspec.yaml assets/ + flutter pub get + flutter build ios --release --tree-shake-icons --no-codesign + mkdir -p Payload + mv build/ios/iphoneos/Runner.app Payload + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)" + brew install zip + zip -r ios-release.ipa Payload + - name: Release iOS + uses: ncipollo/release-action@v1 + with: + allowUpdates: true + artifacts: "ios-release.ipa" token: ${{ secrets.ACTION_TOKEN }} tag: ${{ github.event.inputs.TAG }} - diff --git a/lib/internal/hive.dart b/lib/internal/hive.dart index c2383e0..11917be 100644 --- a/lib/internal/hive.dart +++ b/lib/internal/hive.dart @@ -1,10 +1,12 @@ import 'dart:io'; import 'package:collection/collection.dart'; +import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; import 'package:path_provider/path_provider.dart'; +import '../model/announcement.dart'; import '../model/bangumi.dart'; import '../model/bangumi_row.dart'; import '../model/carousel.dart'; @@ -30,6 +32,8 @@ class MyHive { static const int mikanSeason = mikanSubgroup + 1; static const int mikanYearSeason = mikanSeason + 1; static const int mikanRecordItem = mikanYearSeason + 1; + static const int mikanAnnouncement = mikanRecordItem + 1; + static const int mikanAnnouncementNode = mikanAnnouncement + 1; static late final Box settings; static late final Box db; @@ -54,6 +58,8 @@ class MyHive { Hive.registerAdapter(SeasonAdapter()); Hive.registerAdapter(YearSeasonAdapter()); Hive.registerAdapter(RecordItemAdapter()); + Hive.registerAdapter(AnnouncementAdapter()); + Hive.registerAdapter(AnnouncementNodeAdapter()); db = await Hive.openBox(HiveBoxKey.db); settings = await Hive.openBox(HiveBoxKey.settings); MikanUrls.baseUrl = MyHive.getMirrorUrl(); @@ -203,17 +209,29 @@ class MyHive { return settings.put(SettingsHiveKey.tabletMode, mode.name); } - static double getCardRatio() { + static Decimal getCardRatio() { final value = settings.get( SettingsHiveKey.cardRatio, defaultValue: '0.9', ); - return double.parse(value); + return Decimal.parse(value); } - static Future setCardRatio(double ratio) { + static Future setCardRatio(Decimal ratio) { return settings.put(SettingsHiveKey.cardRatio, ratio.toString()); } + + static Decimal getCardWidth() { + final value = settings.get( + SettingsHiveKey.cardWidth, + defaultValue: '200.0', + ); + return Decimal.parse(value); + } + + static Future setCardWidth(Decimal width) { + return settings.put(SettingsHiveKey.cardWidth, width.toString()); + } } class HiveDBKey { @@ -242,9 +260,10 @@ class SettingsHiveKey { static const String themeMode = 'THEME_MODE'; static const String mirrorUrl = 'MIRROR_URL'; static const String cardRatio = 'CARD_RATIO'; + static const String cardWidth = 'CARD_WIDTH'; + static const String cardStyle = 'CARD_STYLE'; static const String tabletMode = 'TABLET_MODE'; static const String dynamicColor = 'DYNAMIC_COLOR'; - static const String cardStyle = 'cardStyle'; } enum TabletMode { diff --git a/lib/internal/http.dart b/lib/internal/http.dart index 20265ed..82adabb 100644 --- a/lib/internal/http.dart +++ b/lib/internal/http.dart @@ -41,11 +41,11 @@ class MikanTransformer extends SyncTransformer { RequestOptions options, ResponseBody response, ) async { - final transformResponse = await super.transformResponse(options, response); - if (transformResponse is String) { + final rep = await super.transformResponse(options, response); + if (rep is String) { final String? func = options.extra['$MikanFunc']; if (func.isNotBlank) { - final document = parse(transformResponse); + final document = parse(rep); MikanUrls.baseUrl = options.uri.origin; switch (func) { case MikanFunc.season: @@ -81,10 +81,10 @@ class MikanTransformer extends SyncTransformer { final extra = options.extra['$ExtraUrl']; if (extra == ExtraUrl.fontsManifest) { - return jsonDecode(transformResponse); + return jsonDecode(rep); } } - return transformResponse; + return rep; } } diff --git a/lib/internal/resolver.dart b/lib/internal/resolver.dart index 5d3498f..97a32a0 100644 --- a/lib/internal/resolver.dart +++ b/lib/internal/resolver.dart @@ -4,6 +4,7 @@ import 'package:collection/collection.dart'; import 'package:html/dom.dart'; import 'package:jiffy/jiffy.dart'; +import '../model/announcement.dart'; import '../model/bangumi.dart'; import '../model/bangumi_details.dart'; import '../model/bangumi_row.dart'; @@ -400,6 +401,7 @@ class Resolver { final List carousels = parseCarousel(document); final List years = parseYearSeason(document); final User user = parseUser(document); + final List annos = parseAnnouncement(document); final Map> groupedRss = groupBy(rss, (it) => it.id!); return Index( @@ -408,6 +410,7 @@ class Resolver { rss: groupedRss, carousels: carousels, user: user, + announcements: annos, ); } @@ -834,4 +837,35 @@ class Resolver { } return bangumis; } + + static List parseAnnouncement(Document document) { + final annos = []; + final eles = + document.querySelectorAll('.announcement-popover-content > div'); + for (final ele in eles) { + final date = ele.querySelector('.anndate'); + date!.remove(); + final nodes = []; + for (final e in ele.nodes) { + if (e is Element) { + if (e.localName == 'a') { + nodes.add( + AnnouncementNode( + text: '{${e.text}}', + place: e.attributes['href'], + type: 'url', + ), + ); + continue; + } else if (e.localName == 'b') { + nodes.add(AnnouncementNode(text: '{${e.text}}', type: 'bold')); + continue; + } + } + nodes.add(AnnouncementNode(text: e.text ?? '')); + } + annos.add(Announcement(date: date.text, nodes: nodes)); + } + return annos; + } } diff --git a/lib/mikan_route.dart b/lib/mikan_route.dart index 0d3fce0..9bd2b03 100644 --- a/lib/mikan_route.dart +++ b/lib/mikan_route.dart @@ -15,6 +15,7 @@ import '../../model/season.dart'; import '../../model/season_gallery.dart'; import '../../model/subgroup.dart'; import '../../model/year_season.dart'; +import 'ui/pages/announcement.dart'; import 'ui/pages/bangumi.dart'; import 'ui/pages/fonts.dart'; import 'ui/pages/forgot_password.dart'; @@ -41,6 +42,16 @@ FFRouteSettings getRouteSettings({ final Map safeArguments = arguments ?? const {}; switch (name) { + case '/announcements': + return FFRouteSettings( + name: name, + arguments: arguments, + builder: () => Announcements( + key: asT( + safeArguments['key'], + ), + ), + ); case '/bangumi': return FFRouteSettings( name: name, @@ -80,7 +91,7 @@ FFRouteSettings getRouteSettings({ return FFRouteSettings( name: name, arguments: arguments, - builder: () => FontsFragment( + builder: () => Fonts( key: asT( safeArguments['key'], ), diff --git a/lib/mikan_routes.dart b/lib/mikan_routes.dart index fd419f2..2ad5ce6 100644 --- a/lib/mikan_routes.dart +++ b/lib/mikan_routes.dart @@ -16,6 +16,7 @@ import '../../model/year_season.dart'; /// The routeNames auto generated by https://github.com/fluttercandies/ff_annotation_route const List routeNames = [ + '/announcements', '/bangumi', '/bangumi/season', '/fonts', @@ -38,6 +39,11 @@ const List routeNames = [ class Routes { const Routes._(); + /// '/announcements' + /// + /// [name] : '/announcements' + static const _Announcements announcements = _Announcements(); + /// '/bangumi' /// /// [name] : '/bangumi' @@ -151,6 +157,22 @@ class Routes { static const _SubscribedSeason subscribedSeason = _SubscribedSeason(); } +class _Announcements { + const _Announcements(); + + String get name => '/announcements'; + + Map d({ + Key? key, + }) => + { + 'key': key, + }; + + @override + String toString() => name; +} + class _Bangumi { const _Bangumi(); diff --git a/lib/model/announcement.dart b/lib/model/announcement.dart new file mode 100644 index 0000000..1fa1594 --- /dev/null +++ b/lib/model/announcement.dart @@ -0,0 +1,67 @@ +import 'package:hive_flutter/hive_flutter.dart'; + +import '../internal/hive.dart'; + +part 'announcement.g.dart'; + +@HiveType(typeId: MyHive.mikanAnnouncement) +class Announcement extends HiveObject { + Announcement({ + required this.date, + required this.nodes, + }); + + @HiveField(0) + late String date; + @HiveField(1) + late List nodes; + + late final text = () { + final sb = StringBuffer() + ..write('{') + ..write(date) + ..write('} '); + for (final node in nodes) { + sb.write(node.text); + } + return sb.toString(); + }(); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Announcement && + runtimeType == other.runtimeType && + date == other.date; + + @override + int get hashCode => date.hashCode; + + @override + String toString() { + return 'Announcement{date: $date, nodes: $nodes, text: $text}'; + } +} + + + +@HiveType(typeId: MyHive.mikanAnnouncementNode) +class AnnouncementNode extends HiveObject { + AnnouncementNode({ + required this.text, + this.type, + this.place, + }); + + @HiveField(0) + late String text; + @HiveField(1) + String? type; + @HiveField(2) + String? place; + + @override + String toString() { + return 'AnnouncementNode{text: $text, type: $type, place: $place}'; + } +} diff --git a/lib/model/announcement.g.dart b/lib/model/announcement.g.dart new file mode 100644 index 0000000..c51db25 --- /dev/null +++ b/lib/model/announcement.g.dart @@ -0,0 +1,81 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'announcement.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class AnnouncementAdapter extends TypeAdapter { + @override + final int typeId = 11; + + @override + Announcement read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Announcement( + date: fields[0] as String, + nodes: (fields[1] as List).cast(), + ); + } + + @override + void write(BinaryWriter writer, Announcement obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.date) + ..writeByte(1) + ..write(obj.nodes); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AnnouncementAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class AnnouncementNodeAdapter extends TypeAdapter { + @override + final int typeId = 12; + + @override + AnnouncementNode read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return AnnouncementNode( + place: fields[3] as String?, + text: fields[0] as String, + ); + } + + @override + void write(BinaryWriter writer, AnnouncementNode obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.text) + ..writeByte(3) + ..write(obj.place); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AnnouncementNodeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/model/bangumi_details.dart b/lib/model/bangumi_details.dart index 81323d9..6ab991b 100644 --- a/lib/model/bangumi_details.dart +++ b/lib/model/bangumi_details.dart @@ -10,7 +10,7 @@ class BangumiDetail { late String intro; late Map subgroupBangumis; - late final String share = '$name:${MikanUrls.baseUrl}${MikanUrls.bangumi}/$id'; + late final String share = '$name\n${MikanUrls.bangumi}/$id'; @override bool operator ==(Object other) => diff --git a/lib/model/index.dart b/lib/model/index.dart index 9d6025c..e71f6fa 100644 --- a/lib/model/index.dart +++ b/lib/model/index.dart @@ -1,6 +1,7 @@ import 'package:hive/hive.dart'; import '../internal/hive.dart'; +import 'announcement.dart'; import 'bangumi_row.dart'; import 'carousel.dart'; import 'record_item.dart'; @@ -17,6 +18,7 @@ class Index extends HiveObject { required this.rss, required this.carousels, this.user, + this.announcements, }); @HiveField(0) @@ -34,6 +36,9 @@ class Index extends HiveObject { @HiveField(4) final User? user; + @HiveField(5) + final List? announcements; + @override bool operator ==(Object other) => identical(this, other) || diff --git a/lib/model/index.g.dart b/lib/model/index.g.dart index f9cf43e..5d9e946 100644 --- a/lib/model/index.g.dart +++ b/lib/model/index.g.dart @@ -23,13 +23,14 @@ class IndexAdapter extends TypeAdapter { MapEntry(k as String, (v as List).cast())), carousels: (fields[3] as List).cast(), user: fields[4] as User?, + announcements: (fields[5] as List?)?.cast(), ); } @override void write(BinaryWriter writer, Index obj) { writer - ..writeByte(5) + ..writeByte(6) ..writeByte(0) ..write(obj.years) ..writeByte(1) @@ -39,7 +40,9 @@ class IndexAdapter extends TypeAdapter { ..writeByte(3) ..write(obj.carousels) ..writeByte(4) - ..write(obj.user); + ..write(obj.user) + ..writeByte(5) + ..write(obj.announcements); } @override diff --git a/lib/model/record_details.dart b/lib/model/record_details.dart index 5608d18..44e4010 100644 --- a/lib/model/record_details.dart +++ b/lib/model/record_details.dart @@ -35,8 +35,8 @@ class RecordDetail { if (id.isNotBlank) { sb ..write('番组地址:') - ..write(MikanUrls.baseUrl) ..write(MikanUrls.bangumi) + ..write('/') ..write(id) ..write('\n'); } @@ -73,24 +73,24 @@ class RecordDetail { ..write(tags.join(',')) ..write('\n'); } - if (cover.isNotBlank) { - sb - ..write('封面地址:') - ..write(cover) - ..write('\n'); - } - if (magnet.isNotBlank) { - sb - ..write('磁链地址:') - ..write(magnet) - ..write('\n'); - } - if (torrent.isNotBlank) { - sb - ..write('种子地址:') - ..write(torrent) - ..write('\n'); - } + // if (cover.isNotBlank) { + // sb + // ..write('封面地址:') + // ..write(cover) + // ..write('\n'); + // } + // if (magnet.isNotBlank) { + // sb + // ..write('磁链地址:') + // ..write(magnet) + // ..write('\n'); + // } + // if (torrent.isNotBlank) { + // sb + // ..write('种子地址:') + // ..write(torrent) + // ..write('\n'); + // } return sb.toString(); }(); diff --git a/lib/model/record_item.dart b/lib/model/record_item.dart index 4c7b26f..fef7472 100644 --- a/lib/model/record_item.dart +++ b/lib/model/record_item.dart @@ -111,24 +111,24 @@ class RecordItem { ..write(tags.join(',')) ..write('\n'); } - if (cover.isNotBlank) { - sb - ..write('封面地址:') - ..write(cover) - ..write('\n'); - } - if (magnet.isNotBlank) { - sb - ..write('磁链地址:') - ..write(magnet) - ..write('\n'); - } - if (torrent.isNotBlank) { - sb - ..write('种子地址:') - ..write(torrent) - ..write('\n'); - } + // if (cover.isNotBlank) { + // sb + // ..write('封面地址:') + // ..write(cover) + // ..write('\n'); + // } + // if (magnet.isNotBlank) { + // sb + // ..write('磁链地址:') + // ..write(magnet) + // ..write('\n'); + // } + // if (torrent.isNotBlank) { + // sb + // ..write('种子地址:') + // ..write(torrent) + // ..write('\n'); + // } return sb.toString(); }(); } diff --git a/lib/providers/bangumi_model.dart b/lib/providers/bangumi_model.dart index 3b5ffb8..08a1fc2 100644 --- a/lib/providers/bangumi_model.dart +++ b/lib/providers/bangumi_model.dart @@ -54,7 +54,7 @@ class BangumiModel extends BaseModel { final resp = await Repo.bangumi(id); if (resp.success) { _bangumiDetail = resp.data; - '加载成功'.toast(); + '加载完成'.toast(); _refreshFlag++; notifyListeners(); return IndicatorResult.success; diff --git a/lib/providers/index_model.dart b/lib/providers/index_model.dart index 1ebae33..9c4a022 100644 --- a/lib/providers/index_model.dart +++ b/lib/providers/index_model.dart @@ -6,6 +6,7 @@ import 'package:easy_refresh/easy_refresh.dart'; import '../internal/extension.dart'; import '../internal/hive.dart'; import '../internal/repo.dart'; +import '../model/announcement.dart'; import '../model/bangumi_row.dart'; import '../model/carousel.dart'; import '../model/index.dart'; @@ -23,6 +24,7 @@ class IndexModel extends BaseModel { .cast(); final index = MyHive.db.get(HiveDBKey.mikanIndex); _bindIndexData(index); + refresh(); } List _years = []; @@ -31,6 +33,7 @@ class IndexModel extends BaseModel { List _carousels = []; Season? _selectedSeason; User? _user; + List? _announcements; BangumiRow? _selectedBangumiRow; BangumiRow? get selectedBangumiRow => _selectedBangumiRow; @@ -42,6 +45,8 @@ class IndexModel extends BaseModel { Season? get selectedSeason => _selectedSeason; + List? get announcements => _announcements; + List get ovas => _ovas; final SubscribedModel _subscribedModel; @@ -94,6 +99,7 @@ class IndexModel extends BaseModel { _selectedBangumiRow = _bangumiRows[0]; _carousels = index.carousels; _user = index.user; + _announcements = index.announcements; if (years.isSafeNotEmpty) { for (final YearSeason year in years) { _selectedSeason = diff --git a/lib/providers/record_detail_model.dart b/lib/providers/record_detail_model.dart index bd9cb61..9831e2b 100644 --- a/lib/providers/record_detail_model.dart +++ b/lib/providers/record_detail_model.dart @@ -18,7 +18,7 @@ class RecordDetailModel extends BaseModel { final resp = await Repo.details(url); if (resp.success) { _recordDetail = resp.data; - '加载成功'.toast(); + '加载完成'.toast(); notifyListeners(); return IndicatorResult.success; } else { diff --git a/lib/providers/subgroup_model.dart b/lib/providers/subgroup_model.dart index ce5657d..f07625f 100644 --- a/lib/providers/subgroup_model.dart +++ b/lib/providers/subgroup_model.dart @@ -20,7 +20,7 @@ class SubgroupModel extends BaseModel { if (resp.success) { _galleries = resp.data; notifyListeners(); - '加载成功'.toast(); + '加载完成'.toast(); return IndicatorResult.success; } else { '加载字幕组作品年表失败 ${resp.msg ?? ''}'.toast(); diff --git a/lib/ui/fragments/bangumi_sliver_grid.dart b/lib/ui/fragments/bangumi_sliver_grid.dart index 3e35f54..3ae9829 100644 --- a/lib/ui/fragments/bangumi_sliver_grid.dart +++ b/lib/ui/fragments/bangumi_sliver_grid.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:provider/provider.dart'; @@ -30,27 +32,42 @@ class BangumiSliverGridFragment extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final margins = context.margins; return SliverPadding( padding: edgeH24B16, sliver: ValueListenableBuilder( valueListenable: MyHive.settings.listenable( - keys: [SettingsHiveKey.cardRatio, SettingsHiveKey.cardStyle], + keys: [ + SettingsHiveKey.cardRatio, + SettingsHiveKey.cardStyle, + SettingsHiveKey.cardWidth, + ], ), builder: (context, _, child) { - final cardRatio = MyHive.getCardRatio(); + final cardRatio = MyHive.getCardRatio().toDouble(); final cardStyle = MyHive.getCardStyle(); + final cardWidth = MyHive.getCardWidth().toDouble(); final build = cardStyle == 1 ? _buildItemStyle1 : cardStyle == 2 ? _buildItemStyle2 : cardStyle == 3 ? _buildItemStyle3 - : _buildItemStyle1; + : cardStyle == 4 + ? _buildItemStyle4 + : _buildItemStyle1; + final size = calcGridItemSizeWithMaxCrossAxisExtent( + crossAxisExtent: context.screenWidth - 48.0, + maxCrossAxisExtent: cardWidth, + crossAxisSpacing: margins, + childAspectRatio: cardRatio, + ); + final imageWidth = (size.width * context.devicePixelRatio).ceil(); return SliverGrid( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - crossAxisSpacing: context.margins, - mainAxisSpacing: context.margins, - maxCrossAxisExtent: 240.0, + crossAxisSpacing: margins, + mainAxisSpacing: margins, + maxCrossAxisExtent: cardWidth, childAspectRatio: cardRatio, ), delegate: SliverChildBuilderDelegate( @@ -58,7 +75,7 @@ class BangumiSliverGridFragment extends StatelessWidget { return build( context, theme, - index, + imageWidth, bangumis[index], ); }, @@ -70,13 +87,69 @@ class BangumiSliverGridFragment extends StatelessWidget { ); } + Widget _buildItemStyle4( + BuildContext context, + ThemeData theme, + int imageWidth, + Bangumi bangumi, + ) { + final currFlag = '$flag:bangumi:${bangumi.id}:${bangumi.cover}'; + final cover = _buildBangumiItemCover(imageWidth, currFlag, bangumi); + return ScalableCard( + onTap: () { + if (bangumi.grey) { + '此番组下暂无作品'.toast(); + } else { + Navigator.pushNamed( + context, + Routes.bangumi.name, + arguments: Routes.bangumi.d( + heroTag: currFlag, + bangumiId: bangumi.id, + cover: bangumi.cover, + title: bangumi.name, + ), + ); + } + }, + child: Stack( + children: [ + Positioned.fill(child: cover), + if (bangumi.num != null && bangumi.num! > 0) + PositionedDirectional( + top: 14.0, + end: 12.0, + child: Container( + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + color: theme.colorScheme.error, + shape: const StadiumBorder(), + ), + padding: edgeH6V2, + child: Text( + bangumi.num! > 99 ? '99+' : '+${bangumi.num}', + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onError, + height: 1.25, + ), + ), + ), + ), + PositionedDirectional( + child: _buildSubscribeButton(theme, bangumi, currFlag), + ), + ], + ), + ); + } + Widget _buildItemStyle3( BuildContext context, ThemeData theme, - int index, + int imageWidth, Bangumi bangumi, ) { - final currFlag = '$flag:bangumi:$index:${bangumi.id}:${bangumi.cover}'; + final currFlag = '$flag:bangumi:${bangumi.id}:${bangumi.cover}'; final cover = Container( foregroundDecoration: const BoxDecoration( gradient: LinearGradient( @@ -89,7 +162,7 @@ class BangumiSliverGridFragment extends StatelessWidget { stops: [0.68, 1.0], ), ), - child: _buildBangumiItemCover(currFlag, bangumi), + child: _buildBangumiItemCover(imageWidth, currFlag, bangumi), ); return ScalableCard( onTap: () { @@ -113,7 +186,7 @@ class BangumiSliverGridFragment extends StatelessWidget { Positioned.fill(child: cover), if (bangumi.num != null && bangumi.num! > 0) PositionedDirectional( - top: 12.0, + top: 14.0, end: 12.0, child: Container( clipBehavior: Clip.antiAlias, @@ -132,8 +205,6 @@ class BangumiSliverGridFragment extends StatelessWidget { ), ), PositionedDirectional( - start: 4.0, - top: 4.0, child: _buildSubscribeButton(theme, bangumi, currFlag), ), PositionedDirectional( @@ -146,8 +217,8 @@ class BangumiSliverGridFragment extends StatelessWidget { bangumi.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium! - .copyWith(color: Colors.white), + style: + theme.textTheme.titleSmall!.copyWith(color: Colors.white), ), if (bangumi.updateAt.isNotBlank) Text( @@ -168,13 +239,13 @@ class BangumiSliverGridFragment extends StatelessWidget { Widget _buildItemStyle2( BuildContext context, ThemeData theme, - int index, + int imageWidth, Bangumi bangumi, ) { - final currFlag = '$flag:bangumi:$index:${bangumi.id}:${bangumi.cover}'; - final cover = _buildBangumiItemCover(currFlag, bangumi); + final currFlag = '$flag:bangumi:${bangumi.id}:${bangumi.cover}'; + final cover = _buildBangumiItemCover(imageWidth, currFlag, bangumi); return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( child: ScalableCard( @@ -203,7 +274,7 @@ class BangumiSliverGridFragment extends StatelessWidget { ), if (bangumi.num != null && bangumi.num! > 0) PositionedDirectional( - top: 12.0, + top: 14.0, end: 12.0, child: Container( clipBehavior: Clip.antiAlias, @@ -222,8 +293,6 @@ class BangumiSliverGridFragment extends StatelessWidget { ), ), PositionedDirectional( - start: 4.0, - top: 4.0, child: _buildSubscribeButton(theme, bangumi, currFlag), ), ], @@ -235,7 +304,7 @@ class BangumiSliverGridFragment extends StatelessWidget { bangumi.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium, + style: theme.textTheme.titleSmall, ), if (bangumi.updateAt.isNotBlank) Text( @@ -252,13 +321,13 @@ class BangumiSliverGridFragment extends StatelessWidget { Widget _buildItemStyle1( BuildContext context, ThemeData theme, - int index, + int imageWidth, Bangumi bangumi, ) { - final currFlag = '$flag:bangumi:$index:${bangumi.id}:${bangumi.cover}'; - final cover = _buildBangumiItemCover(currFlag, bangumi); + final currFlag = '$flag:bangumi:${bangumi.id}:${bangumi.cover}'; + final cover = _buildBangumiItemCover(imageWidth, currFlag, bangumi); return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( child: ScalableCard( @@ -286,7 +355,7 @@ class BangumiSliverGridFragment extends StatelessWidget { child: cover, ), PositionedDirectional( - top: 12.0, + top: 14.0, end: 12.0, child: Container( clipBehavior: Clip.antiAlias, @@ -319,7 +388,7 @@ class BangumiSliverGridFragment extends StatelessWidget { bangumi.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium, + style: theme.textTheme.titleSmall, ), if (bangumi.updateAt.isNotBlank) Text( @@ -386,41 +455,23 @@ class BangumiSliverGridFragment extends StatelessWidget { } Widget _buildBangumiItemCover( + int cacheWidth, String currFlag, Bangumi bangumi, ) { - final provider = CacheImage(bangumi.cover); - return Image( - image: provider, - isAntiAlias: true, - loadingBuilder: (_, child, event) { - return Hero( - tag: currFlag, - child: event == null ? child : _buildBangumiItemPlaceholder(), - ); - }, - errorBuilder: (_, __, ___) { - return Hero( - tag: currFlag, - child: _buildBangumiItemError(), - ); - }, - frameBuilder: (_, __, ___, ____) { - return _buildBackgroundCover( - bangumi, - provider, - ); - }, - ); - } - - Widget _buildBangumiItemPlaceholder() { - return Container( - padding: edge28, - child: Center( - child: Image.asset( - 'assets/mikan.png', + return Hero( + tag: currFlag, + child: FadeInImage( + placeholder: const AssetImage('assets/mikan.png'), + image: ResizeImage.resizeIfNeeded( + cacheWidth, + null, + CacheImage(bangumi.cover), ), + fit: BoxFit.cover, + imageErrorBuilder: (_, __, ___) { + return _buildBangumiItemError(); + }, ), ); } @@ -436,22 +487,24 @@ class BangumiSliverGridFragment extends StatelessWidget { ), ); } +} - Widget _buildBackgroundCover( - Bangumi bangumi, - ImageProvider imageProvider, - ) { - return Container( - decoration: BoxDecoration( - borderRadius: borderRadius12, - image: DecorationImage( - image: imageProvider, - fit: BoxFit.cover, - colorFilter: bangumi.grey - ? const ColorFilter.mode(Colors.grey, BlendMode.color) - : null, - ), - ), - ); - } +Size calcGridItemSizeWithMaxCrossAxisExtent({ + required double crossAxisExtent, + required double maxCrossAxisExtent, + required double crossAxisSpacing, + required double childAspectRatio, +}) { + int crossAxisCount = + (crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)).ceil(); + // Ensure a minimum count of 1, can be zero and result in an infinite extent + // below when the window size is 0. + crossAxisCount = math.max(1, crossAxisCount); + final double usableCrossAxisExtent = math.max( + 0.0, + crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1), + ); + final double childCrossAxisExtent = usableCrossAxisExtent / crossAxisCount; + final double childMainAxisExtent = childCrossAxisExtent / childAspectRatio; + return Size(childCrossAxisExtent, childMainAxisExtent); } diff --git a/lib/ui/fragments/card_ratio.dart b/lib/ui/fragments/card_ratio.dart index 9e4bedc..75fc2d0 100644 --- a/lib/ui/fragments/card_ratio.dart +++ b/lib/ui/fragments/card_ratio.dart @@ -1,12 +1,9 @@ +import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:hive_flutter/hive_flutter.dart'; -import 'package:waterfall_flow/waterfall_flow.dart'; -import '../../internal/delegate.dart'; import '../../internal/hive.dart'; import '../../topvars.dart'; -import '../../widget/ripple_tap.dart'; -import '../../widget/scalable_tap.dart'; import '../../widget/sliver_pinned_header.dart'; class CardRatio extends StatelessWidget { @@ -14,121 +11,34 @@ class CardRatio extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final ratios = [ - 0.5, - 0.55, - 0.6, - 0.65, - 0.7, - 0.75, - 0.8, - 0.85, - 0.9, - 0.95, - 1.0, - 1.05, - ]; - ratios.shuffle(); return Scaffold( body: CustomScrollView( slivers: [ - const SliverPinnedAppBar(title: '卡片比例'), - SliverPadding( - padding: const EdgeInsets.symmetric( - horizontal: 24.0, - vertical: 8.0, - ), - sliver: ValueListenableBuilder( - valueListenable: MyHive.settings.listenable( - keys: [SettingsHiveKey.cardRatio], - ), - builder: (context, _, child) { - final selected = MyHive.getCardRatio(); - return SliverWaterfallFlow( - gridDelegate: - const SliverWaterfallFlowDelegateWithMinCrossAxisExtent( - crossAxisSpacing: 8.0, - mainAxisSpacing: 8.0, - minCrossAxisExtent: 120.0, - ), - delegate: SliverChildBuilderDelegate( - (context, index) { - final ratio = ratios[index]; - final isSelected = selected == ratio; - final color = isSelected - ? theme.colorScheme.primary - : theme.colorScheme.secondary; - return RippleTap( - borderRadius: borderRadius12, - onTap: () { - MyHive.setCardRatio(ratio); - }, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: AspectRatio( - aspectRatio: ratio, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Stack( - children: [ - ScalableCard( - onTap: () {}, - child: const SizedBox.expand(), - ), - PositionedDirectional( - end: 12.0, - top: 12.0, - child: Container( - clipBehavior: Clip.antiAlias, - decoration: ShapeDecoration( - color: color, - shape: const StadiumBorder(), - ), - padding: edgeH6V2, - child: Text( - ratio.toString(), - style: theme.textTheme.labelMedium - ?.copyWith( - color: isSelected - ? theme.colorScheme.onPrimary - : theme - .colorScheme.onSecondary, - ), - ), - ), - ), - ], - ), - ), - sizedBoxH8, - ScalableCard( - child: const FractionallySizedBox( - widthFactor: 0.9, - child: sizedBoxH16, - ), - onTap: () {}, - ), - sizedBoxH8, - ScalableCard( - child: const FractionallySizedBox( - widthFactor: 0.72, - child: sizedBoxH12, - ), - onTap: () {}, - ), - ], - ), - ), - ), - ); + const SliverPinnedAppBar( + title: '卡片比例', + maxExtent: 120.0, + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: ValueListenableBuilder( + valueListenable: MyHive.settings.listenable( + keys: [SettingsHiveKey.cardRatio], + ), + builder: (context, _, child) { + final value = MyHive.getCardRatio(); + return Slider( + value: value.toDouble(), + onChanged: (v) { + MyHive.setCardRatio(Decimal.parse(v.toString())); }, - childCount: ratios.length, - ), - ); - }, + min: 0.4, + max: 1.2, + divisions: 40, + label: value.toStringAsFixed(2), + ); + }, + ), ), ), sliverSizedBoxH24WithNavBarHeight(context), diff --git a/lib/ui/fragments/card_style.dart b/lib/ui/fragments/card_style.dart index e6a548a..35f5a8c 100644 --- a/lib/ui/fragments/card_style.dart +++ b/lib/ui/fragments/card_style.dart @@ -3,8 +3,6 @@ import 'package:hive_flutter/hive_flutter.dart'; import '../../internal/hive.dart'; import '../../topvars.dart'; -import '../../widget/ripple_tap.dart'; -import '../../widget/scalable_tap.dart'; import '../../widget/sliver_pinned_header.dart'; class CardStyle extends StatelessWidget { @@ -12,37 +10,38 @@ class CardStyle extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); return Scaffold( body: CustomScrollView( slivers: [ - const SliverPinnedAppBar(title: '卡片样式'), - SliverPadding( - padding: const EdgeInsets.symmetric( - horizontal: 24.0, - vertical: 8.0, - ), - sliver: ValueListenableBuilder( - valueListenable: MyHive.settings.listenable( - keys: [SettingsHiveKey.cardStyle, SettingsHiveKey.cardRatio], + const SliverPinnedAppBar( + title: '卡片样式', + maxExtent: 120.0, + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: ValueListenableBuilder( + valueListenable: MyHive.settings.listenable( + keys: [SettingsHiveKey.cardStyle], + ), + builder: (context, _, child) { + final value = MyHive.getCardStyle(); + return SegmentedButton( + showSelectedIcon: false, + segments: List.generate(4, (index) { + final v = index + 1; + return ButtonSegment( + value: v, + label: Text('样式$v'), + ); + }), + onSelectionChanged: (v){ + MyHive.setCardStyle(v.first); + }, + selected: {value}, + ); + }, ), - builder: (context, _, child) { - final selected = MyHive.getCardStyle(); - final ratio = MyHive.getCardRatio(); - return SliverGrid( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisSpacing: 8.0, - mainAxisSpacing: 8.0, - crossAxisCount: 3, - childAspectRatio: ratio, - ), - delegate: SliverChildListDelegate([ - _style1(theme, selected == 1), - _style2(theme, selected == 2), - _style3(theme, selected == 3), - ]), - ); - }, ), ), sliverSizedBoxH24WithNavBarHeight(context), @@ -50,255 +49,4 @@ class CardStyle extends StatelessWidget { ), ); } - - Widget _style3( - ThemeData theme, - bool isSelected, - ) { - final color = - isSelected ? theme.colorScheme.primary : theme.colorScheme.secondary; - return RippleTap( - borderRadius: borderRadius12, - onTap: () { - MyHive.setCardStyle(3); - }, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Stack( - children: [ - ScalableCard( - onTap: () { }, - child: const SizedBox.expand(), - ), - PositionedDirectional( - start: 12.0, - top: 12.0, - child: Icon( - isSelected - ? Icons.favorite_rounded - : Icons.favorite_outline_rounded, - color: color, - ), - ), - PositionedDirectional( - end: 12.0, - top: 12.0, - child: Container( - clipBehavior: Clip.antiAlias, - decoration: ShapeDecoration( - color: color, - shape: const StadiumBorder(), - ), - padding: edgeH6V2, - child: Text( - '样式3', - style: theme.textTheme.labelMedium?.copyWith( - color: isSelected - ? theme.colorScheme.onPrimary - : theme.colorScheme.onSecondary, - height: 1.25, - ), - ), - ), - ), - PositionedDirectional( - bottom: 12.0, - start: 12.0, - end: 12.0, - child: Column( - children: [ - Text( - '你好,Mikan', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall, - ), - Text( - '2099/12/31', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall, - ), - ], - ), - ) - ], - ), - ), - ], - ), - ), - ); - } - - Widget _style2( - ThemeData theme, - bool isSelected, - ) { - final color = - isSelected ? theme.colorScheme.primary : theme.colorScheme.secondary; - return RippleTap( - borderRadius: borderRadius12, - onTap: () { - MyHive.setCardStyle(2); - }, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Stack( - children: [ - ScalableCard( - onTap: () { }, - child: const SizedBox.expand(), - ), - PositionedDirectional( - start: 12.0, - top: 12.0, - child: Icon( - isSelected - ? Icons.favorite_rounded - : Icons.favorite_outline_rounded, - color: color, - ), - ), - PositionedDirectional( - end: 12.0, - top: 12.0, - child: Container( - clipBehavior: Clip.antiAlias, - decoration: ShapeDecoration( - color: color, - shape: const StadiumBorder(), - ), - padding: edgeH6V2, - child: Text( - '样式2', - style: theme.textTheme.labelMedium?.copyWith( - color: isSelected - ? theme.colorScheme.onPrimary - : theme.colorScheme.onSecondary, - height: 1.25, - ), - ), - ), - ), - ], - ), - ), - sizedBoxH4, - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '你好,Mikan', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall, - ), - Text( - '2099/12/31', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall, - ), - ], - ), - ], - ), - ), - ); - } - - Widget _style1( - ThemeData theme, - bool isSelected, - ) { - final color = - isSelected ? theme.colorScheme.primary : theme.colorScheme.secondary; - return RippleTap( - borderRadius: borderRadius12, - onTap: () { - MyHive.setCardStyle(1); - }, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Stack( - children: [ - ScalableCard( - onTap: () { }, - child: const SizedBox.expand(), - ), - PositionedDirectional( - end: 12.0, - top: 12.0, - child: Container( - clipBehavior: Clip.antiAlias, - decoration: ShapeDecoration( - color: color, - shape: const StadiumBorder(), - ), - padding: edgeH6V2, - child: Text( - '样式1', - style: theme.textTheme.labelMedium?.copyWith( - color: isSelected - ? theme.colorScheme.onPrimary - : theme.colorScheme.onSecondary, - height: 1.25, - ), - ), - ), - ), - ], - ), - ), - sizedBoxH4, - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '你好,Mikan', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall, - ), - Text( - '2099/12/31', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall, - ), - ], - ), - ), - IconButton( - icon: Icon( - isSelected - ? Icons.favorite_rounded - : Icons.favorite_outline_rounded, - color: color, - ), - onPressed: null, - ), - ], - ), - ], - ), - ), - ); - } } diff --git a/lib/ui/fragments/card_width.dart b/lib/ui/fragments/card_width.dart new file mode 100644 index 0000000..853b0e5 --- /dev/null +++ b/lib/ui/fragments/card_width.dart @@ -0,0 +1,49 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +import '../../internal/hive.dart'; +import '../../topvars.dart'; +import '../../widget/sliver_pinned_header.dart'; + +class CardWidth extends StatelessWidget { + const CardWidth({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + const SliverPinnedAppBar( + title: '卡片宽度', + maxExtent: 120.0, + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: ValueListenableBuilder( + valueListenable: MyHive.settings.listenable( + keys: [SettingsHiveKey.cardWidth], + ), + builder: (context, _, child) { + final cardWidth = MyHive.getCardWidth(); + return Slider( + value: cardWidth.toDouble(), + onChanged: (v) { + MyHive.setCardWidth(Decimal.parse(v.toString())); + }, + min: 100.0, + max: 400.0, + divisions: 15, + label: cardWidth.toStringAsFixed(0), + ); + }, + ), + ), + ), + sliverSizedBoxH24WithNavBarHeight(context), + ], + ), + ); + } +} diff --git a/lib/ui/fragments/index.dart b/lib/ui/fragments/index.dart index cf6e4a3..359670b 100644 --- a/lib/ui/fragments/index.dart +++ b/lib/ui/fragments/index.dart @@ -39,7 +39,7 @@ class IndexFragment extends StatefulWidget { State createState() => _IndexFragmentState(); } -class _IndexFragmentState extends LifecycleState { +class _IndexFragmentState extends LifecycleAppState { final _infiniteScrollController = InfiniteScrollController(); Timer? _timer; @@ -479,13 +479,6 @@ class _PinedHeader extends StatelessWidget { child: Container( decoration: BoxDecoration( color: theme.colorScheme.background, - border: Border( - bottom: BorderSide( - color: display - ? theme.colorScheme.surfaceVariant - : Colors.transparent, - ), - ), ), padding: EdgeInsetsDirectional.only( start: 12.0, diff --git a/lib/ui/fragments/settings.dart b/lib/ui/fragments/settings.dart index ec4ccfa..40ea942 100644 --- a/lib/ui/fragments/settings.dart +++ b/lib/ui/fragments/settings.dart @@ -18,6 +18,7 @@ import '../../widget/ripple_tap.dart'; import '../../widget/sliver_pinned_header.dart'; import 'card_ratio.dart'; import 'card_style.dart'; +import 'card_width.dart'; import 'donate.dart'; import 'index.dart'; import 'select_mirror.dart'; @@ -50,6 +51,7 @@ class SettingsPanel extends StatelessWidget { _buildFontManager(context, theme), _buildCardStyle(context, theme), _buildCardRatio(context, theme), + _buildCardWidth(context, theme), _buildTabletMode(context, theme), _buildSection(theme, '更多'), _buildMirror(context, theme), @@ -89,8 +91,9 @@ class SettingsPanel extends StatelessWidget { return SliverPinnedAppBar( title: "Hi, ${hasLogin ? user!.name : "请登录 👉"}", leading: buildAvatar(user?.avatar), - startPadding: 24.0, - height: 90.0, + startPadding: 16.0, + endPadding: 8.0, + minExtent: 64.0, actions: [ IconButton( onPressed: () { @@ -177,9 +180,14 @@ class SettingsPanel extends StatelessWidget { Widget _buildCardRatio(BuildContext context, ThemeData theme) { return RippleTap( onTap: () { + Navigator.pop(context); MBottomSheet.show( context, - (context) => const MBottomSheet(child: CardRatio()), + barrierColor: Colors.transparent, + (context) => const MBottomSheet( + height: 200.0, + child: CardRatio(), + ), ); }, child: Container( @@ -209,12 +217,57 @@ class SettingsPanel extends StatelessWidget { ); } + Widget _buildCardWidth(BuildContext context, ThemeData theme) { + return RippleTap( + onTap: () { + Navigator.pop(context); + MBottomSheet.show( + context, + barrierColor: Colors.transparent, + (context) => const MBottomSheet( + height: 200.0, + child: CardWidth(), + ), + ); + }, + child: Container( + height: 50.0, + padding: edgeH24, + child: Row( + children: [ + Expanded( + child: Text( + '卡片宽度', + style: theme.textTheme.titleMedium, + ), + ), + ValueListenableBuilder( + valueListenable: + MyHive.settings.listenable(keys: [SettingsHiveKey.cardWidth]), + builder: (context, _, child) { + return Text( + MyHive.getCardWidth().toStringAsFixed(0), + style: theme.textTheme.bodyMedium, + ); + }, + ) + ], + ), + ), + ); + } + Widget _buildCardStyle(BuildContext context, ThemeData theme) { return RippleTap( onTap: () { + Navigator.pop(context); MBottomSheet.show( context, - (context) => const MBottomSheet(child: CardStyle()), + barrierColor: Colors.transparent, + (context) => const MBottomSheet( + height: 200.0, + child: CardStyle(), + ), ); }, child: Container( diff --git a/lib/ui/fragments/subscribed.dart b/lib/ui/fragments/subscribed.dart index 06ed267..e4dcd54 100644 --- a/lib/ui/fragments/subscribed.dart +++ b/lib/ui/fragments/subscribed.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:easy_refresh/easy_refresh.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -6,6 +8,7 @@ import 'package:sliver_tools/sliver_tools.dart'; import '../../internal/extension.dart'; import '../../internal/image_provider.dart'; import '../../internal/kit.dart'; +import '../../internal/lifecycle.dart'; import '../../mikan_routes.dart'; import '../../model/bangumi.dart'; import '../../model/record_item.dart'; @@ -13,6 +16,7 @@ import '../../model/season_gallery.dart'; import '../../providers/op_model.dart'; import '../../providers/subscribed_model.dart'; import '../../topvars.dart'; +import '../../widget/infinite_carousel.dart'; import '../../widget/scalable_tap.dart'; import '../../widget/sliver_pinned_header.dart'; import '../components/rss_record_item.dart'; @@ -21,9 +25,46 @@ import 'index.dart'; import 'select_tablet_mode.dart'; @immutable -class SubscribedFragment extends StatelessWidget { +class SubscribedFragment extends StatefulWidget { const SubscribedFragment({super.key}); + @override + State createState() => _SubscribedFragmentState(); +} + +class _SubscribedFragmentState extends LifecycleAppState { + final _infiniteScrollController = InfiniteScrollController(); + + Timer? _timer; + + @override + void initState() { + super.initState(); + _timer = Timer.periodic(const Duration(milliseconds: 3600), (timer) { + if (_infiniteScrollController.hasClients) { + _infiniteScrollController.animateToItem( + (_infiniteScrollController.offset / 300.0).round() + 1, + duration: const Duration(milliseconds: 800), + curve: Curves.easeInOut, + ); + } + }); + } + + @override + void dispose() { + _timer?.cancel(); + _infiniteScrollController.dispose(); + super.dispose(); + } + + @override + void onResume() { + if (mounted) { + Provider.of(context, listen: false).refresh(); + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -41,39 +82,41 @@ class SubscribedFragment extends StatelessWidget { ) { final subscribedModel = Provider.of(context, listen: false); - return EasyRefresh( + return EasyRefresh.builder( onRefresh: subscribedModel.refresh, refreshOnStart: true, header: defaultHeader, - footer: defaultFooter(context), - child: CustomScrollView( - slivers: [ - const _PinedHeader(), - MultiSliver( - pushPinnedChildren: true, - children: [ - _buildRssSection(context, theme), - _buildRssList(context, theme, subscribedModel), - ], - ), - MultiSliver( - pushPinnedChildren: true, - children: [ - _buildSeasonRssSection(theme, subscribedModel), - _buildSeasonRssList(theme, subscribedModel), - ], - ), - MultiSliver( - pushPinnedChildren: true, - children: [ - _buildRssRecordsSection(context, theme), - _buildRssRecordsList(context, theme), - ], - ), - _buildSeeMore(theme, subscribedModel), - sliverSizedBoxH80WithNavBarHeight(context), - ], - ), + childBuilder: (context, physics) { + return CustomScrollView( + physics: physics, + slivers: [ + const _PinedHeader(), + MultiSliver( + pushPinnedChildren: true, + children: [ + _buildRssSection(context, theme), + _buildRssList(context, theme, subscribedModel), + ], + ), + MultiSliver( + pushPinnedChildren: true, + children: [ + _buildSeasonRssSection(theme, subscribedModel), + _buildSeasonRssList(theme, subscribedModel), + ], + ), + MultiSliver( + pushPinnedChildren: true, + children: [ + _buildRssRecordsSection(context, theme), + _buildRssRecordsList(context, theme), + ], + ), + _buildSeeMore(theme, subscribedModel), + sliverSizedBoxH80WithNavBarHeight(context), + ], + ); + }, ); } @@ -245,19 +288,19 @@ class SubscribedFragment extends StatelessWidget { ); } final entries = rss!.entries.toList(growable: false); - return SliverPadding( - padding: edgeH24V8, - sliver: SliverGrid( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 200.0, - crossAxisSpacing: context.margins, - mainAxisSpacing: context.margins, - ), - delegate: SliverChildBuilderDelegate( - (context, index) { - return _buildRssListItem(context, theme, entries[index]); + return SliverToBoxAdapter( + child: SizedBox( + height: 136.0, + child: InfiniteCarousel.builder( + itemBuilder: (context, index, realIndex) { + final entry = entries[index]; + return _buildRssListItem(context, theme, index, entry); }, - childCount: entries.length, + controller: _infiniteScrollController, + itemExtent: 280.0, + itemCount: entries.length, + center: false, + velocityFactor: 0.8, ), ), ); @@ -319,6 +362,7 @@ class SubscribedFragment extends StatelessWidget { Widget _buildRssListItem( BuildContext context, ThemeData theme, + int index, MapEntry> entry, ) { final List records = entry.value; @@ -328,98 +372,108 @@ class SubscribedFragment extends StatelessWidget { final String bangumiId = entry.key; final String badge = recordsLength > 99 ? '99+' : '+$recordsLength'; final String currFlag = 'rss:$bangumiId:$bangumiCover'; - final imageProvider = CacheImage(bangumiCover); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Stack( - children: [ - Positioned.fill( - child: ScalableCard( - onTap: () { - Navigator.pushNamed( - context, - Routes.bangumi.name, - arguments: Routes.bangumi.d( - heroTag: currFlag, - bangumiId: bangumiId, - cover: bangumiCover, - title: record.name, - ), - ); - }, - child: Hero( - tag: currFlag, - child: Tooltip( - message: records.first.name, - child: Image( - image: imageProvider, - fit: BoxFit.cover, - loadingBuilder: (_, child, event) { - return event == null - ? child - : Padding( - padding: edge16, - child: Center( - child: Image.asset( - 'assets/mikan.png', - ), - ), - ); - }, - errorBuilder: (_, __, ___) { - return Padding( - padding: edge16, - child: Center( - child: Image.asset( - 'assets/mikan.png', - colorBlendMode: BlendMode.color, - color: Colors.grey, - ), - ), - ); - }, - ), + return Padding( + padding: const EdgeInsetsDirectional.only( + start: 24.0, + top: 8.0, + bottom: 8.0, + ), + child: Stack( + children: [ + Positioned.fill( + child: ScalableCard( + onTap: () { + Navigator.pushNamed( + context, + Routes.bangumi.name, + arguments: Routes.bangumi.d( + heroTag: currFlag, + bangumiId: bangumiId, + cover: bangumiCover, + title: record.name, + ), + ); + }, + child: Hero( + tag: currFlag, + child: Tooltip( + message: records.first.name, + child: FadeInImage( + placeholder: const AssetImage( + 'assets/mikan.png', ), + image: ResizeImage.resizeIfNeeded( + (280.0 * context.devicePixelRatio).ceil(), + null, + CacheImage(bangumiCover), + ), + fit: BoxFit.cover, ), ), ), - PositionedDirectional( - top: 12.0, - end: 12.0, - child: Container( - clipBehavior: Clip.antiAlias, - decoration: ShapeDecoration( - color: theme.colorScheme.error, - shape: const StadiumBorder(), + ), + ), + PositionedDirectional( + top: 12.0, + end: 12.0, + child: Container( + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + color: theme.colorScheme.error, + shape: const StadiumBorder(), + ), + padding: edgeH6V2, + child: Text( + badge, + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onError, + ), + ), + ), + ), + PositionedDirectional( + bottom: 12.0, + start: 12.0, + end: 12.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + record.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall!.copyWith( + color: Colors.white, + shadows: [ + const BoxShadow( + color: Colors.black38, + blurRadius: 2.0, + spreadRadius: 2.0, + ), + ], ), - padding: edgeH6V2, - child: Text( - badge, - style: theme.textTheme.labelMedium?.copyWith( - color: theme.colorScheme.onError, + ), + if (record.publishAt.isNotBlank) + Text( + record.publishAt, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall!.copyWith( + color: Colors.white.withOpacity(0.87), + shadows: [ + const BoxShadow( + color: Colors.black38, + blurRadius: 2.0, + spreadRadius: 2.0, + ), + ], ), ), - ), - ), - ], + ], + ), ), - ), - sizedBoxH8, - Text( - record.name, - style: theme.textTheme.titleMedium, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - record.publishAt, - style: theme.textTheme.bodySmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + ], + ), ); } @@ -541,12 +595,18 @@ class _PinedHeader extends StatelessWidget { actions: isTablet ? null : [ + IconButton( + onPressed: () { + Navigator.pushNamed(context, Routes.announcements.name); + }, + icon: const Icon(Icons.notifications_none_rounded), + ), IconButton( onPressed: () { showSettingsPanel(context); }, icon: const Icon(Icons.tune_rounded), - ) + ), ], ); }, diff --git a/lib/ui/fragments/theme_color.dart b/lib/ui/fragments/theme_color.dart index 2ebbd21..2f6ca04 100644 --- a/lib/ui/fragments/theme_color.dart +++ b/lib/ui/fragments/theme_color.dart @@ -50,7 +50,6 @@ class _ThemeColorPanelState extends LifecycleAppState { const SliverPinnedAppBar( title: '选择主题色', borderRadius: borderRadiusT28, - bottomBorder: false, ), if (_colorSchemePair != null) SliverToBoxAdapter( diff --git a/lib/ui/pages/announcement.dart b/lib/ui/pages/announcement.dart new file mode 100644 index 0000000..79de422 --- /dev/null +++ b/lib/ui/pages/announcement.dart @@ -0,0 +1,131 @@ +import 'package:easy_refresh/easy_refresh.dart'; +import 'package:ff_annotation_route_core/ff_annotation_route_core.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +import '../../internal/extension.dart'; +import '../../model/announcement.dart'; +import '../../providers/index_model.dart'; +import '../../topvars.dart'; +import '../../widget/placeholder_text.dart'; +import '../../widget/sliver_pinned_header.dart'; + +@FFRoute(name: '/announcements') +class Announcements extends StatelessWidget { + const Announcements({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final model = Provider.of(context, listen: false); + return Scaffold( + body: EasyRefresh( + onRefresh: model.refresh, + header: defaultHeader, + refreshOnStart: true, + child: CustomScrollView( + slivers: [ + const SliverPinnedAppBar(title: '公告'), + Selector?>( + builder: (context, v, child) { + if (v.isNullOrEmpty) { + return SliverFillRemaining( + child: Center( + child: Center( + child: Column( + children: [ + Image.asset( + 'assets/mikan.png', + width: 64.0, + ), + sizedBoxH12, + Text( + '暂无数据', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), + ], + ), + ), + ), + ); + } + return SliverList( + delegate: SliverChildBuilderDelegate( + childCount: v!.length + v.length - 1, + (context, index) { + if (index.isOdd) { + return const Divider( + indent: 24.0, + endIndent: 24.0, + height: 1.0, + thickness: 1.0, + ); + } + final a = v[index ~/ 2]; + return Container( + margin: edgeH24, + padding: edgeV16, + child: PlaceholderText( + a.text, + onMatched: (pos, matched) { + if (pos == 0) { + return TextSpan( + text: matched.group(1), + style: TextStyle( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ); + } + int p = 0; + for (int i = 0; i < a.nodes.length; ++i) { + final n = a.nodes[i]; + if (n.type != null) { + p++; + if (p == pos) { + if (n.type == 'url') { + return TextSpan( + text: matched.group(1), + style: TextStyle( + color: theme.colorScheme.secondary, + fontWeight: FontWeight.w400, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + if (!n.place.isNullOrBlank) { + launchUrlString(n.place!); + } + }, + ); + } + if (n.type == 'bold') { + return TextSpan( + text: matched.group(1), + style: const TextStyle( + fontWeight: FontWeight.w700, + ), + ); + } + } + } + } + return TextSpan(text: matched.group(1)); + }, + ), + ); + }, + ), + ); + }, + selector: (_, model) => model.announcements, + ), + sliverSizedBoxH24WithNavBarHeight(context), + ], + ), + ), + ); + } +} diff --git a/lib/ui/pages/bangumi.dart b/lib/ui/pages/bangumi.dart index be29074..cf81a92 100644 --- a/lib/ui/pages/bangumi.dart +++ b/lib/ui/pages/bangumi.dart @@ -75,16 +75,7 @@ class BangumiPage extends StatelessWidget { builder: (_, ratio, __) { final bgc = headerBackgroundColor.transform(ratio); return Container( - decoration: BoxDecoration( - color: bgc, - border: Border( - bottom: BorderSide( - color: ratio >= 0.99 - ? theme.colorScheme.surfaceVariant - : Colors.transparent, - ), - ), - ), + decoration: BoxDecoration(color: bgc), padding: EdgeInsets.only( top: 12.0 + context.statusBarHeight, left: 12.0, @@ -379,7 +370,7 @@ class BangumiPage extends StatelessWidget { '${detail.name}\n', style: theme.textTheme.titleLarge ?.copyWith(color: theme.secondary), - maxLines: 2, + maxLines: 3, overflow: TextOverflow.ellipsis, ), ), diff --git a/lib/ui/pages/fonts.dart b/lib/ui/pages/fonts.dart index 537873a..d1e6855 100644 --- a/lib/ui/pages/fonts.dart +++ b/lib/ui/pages/fonts.dart @@ -16,8 +16,8 @@ import '../../widget/scalable_tap.dart'; import '../../widget/sliver_pinned_header.dart'; @FFRoute(name: '/fonts') -class FontsFragment extends StatelessWidget { - const FontsFragment({super.key}); +class Fonts extends StatelessWidget { + const Fonts({super.key}); @override Widget build(BuildContext context) { diff --git a/lib/ui/pages/record.dart b/lib/ui/pages/record.dart index d42152e..e38fedd 100644 --- a/lib/ui/pages/record.dart +++ b/lib/ui/pages/record.dart @@ -342,7 +342,10 @@ class Record extends StatelessWidget { } Widget _buildSubscribeBtn( - BuildContext context, ThemeData theme, RecordDetailModel model) { + BuildContext context, + ThemeData theme, + RecordDetailModel model, + ) { return Selector( selector: (_, model) => model.recordDetail?.subscribed ?? false, shouldRebuild: (pre, next) => pre != next, diff --git a/lib/widget/bottom_sheet.dart b/lib/widget/bottom_sheet.dart index 2677e0d..28c74e0 100644 --- a/lib/widget/bottom_sheet.dart +++ b/lib/widget/bottom_sheet.dart @@ -7,18 +7,25 @@ class MBottomSheet extends StatelessWidget { const MBottomSheet({ super.key, required this.child, + this.height, this.heightFactor = 0.618, }); final Widget child; + final double? height; final double heightFactor; - static Future show(BuildContext context, WidgetBuilder builder) { + static Future show( + BuildContext context, + WidgetBuilder builder, { + Color? barrierColor, + }) { return showModalBottomSheet( context: context, isScrollControlled: true, enableDrag: true, isDismissible: true, + barrierColor: barrierColor, backgroundColor: Colors.transparent, builder: builder, ); @@ -26,6 +33,10 @@ class MBottomSheet extends StatelessWidget { @override Widget build(BuildContext context) { + final clipRRect = ClipRRect( + borderRadius: borderRadius28, + child: child, + ); return Padding( padding: EdgeInsets.only( left: 16.0, @@ -35,13 +46,15 @@ class MBottomSheet extends StatelessWidget { ? navKey.currentContext!.statusBarHeight + 16.0 : 16.0, ), - child: FractionallySizedBox( - heightFactor: heightFactor, - child: ClipRRect( - borderRadius: borderRadius28, - child: child, - ), - ), + child: height != null + ? SizedBox( + height: height, + child: clipRRect, + ) + : FractionallySizedBox( + heightFactor: heightFactor, + child: clipRRect, + ), ); } } diff --git a/lib/widget/placeholder_text.dart b/lib/widget/placeholder_text.dart index e0b84ea..f704915 100644 --- a/lib/widget/placeholder_text.dart +++ b/lib/widget/placeholder_text.dart @@ -134,7 +134,7 @@ class PlaceholderText extends StatelessWidget { Widget build(BuildContext context) { final List children = _buildSpans( text, - regExp ?? RegExp(r'#?\{(.+?)}', multiLine: true), + regExp ?? RegExp(r'\{(.+?)}', multiLine: true), onMatched ?? (int position, Match matched) => TextSpan( text: matched.group(1), diff --git a/lib/widget/sliver_pinned_header.dart b/lib/widget/sliver_pinned_header.dart index 14c7038..f5c97d0 100644 --- a/lib/widget/sliver_pinned_header.dart +++ b/lib/widget/sliver_pinned_header.dart @@ -14,9 +14,9 @@ class SliverPinnedAppBar extends StatelessWidget { this.leading, this.actions, this.autoImplLeading = true, - this.bottomBorder = true, this.borderRadius, - this.height, + this.minExtent, + this.maxExtent, this.startPadding, this.endPadding, }); @@ -25,18 +25,18 @@ class SliverPinnedAppBar extends StatelessWidget { final Widget? leading; final List? actions; final bool autoImplLeading; - final bool bottomBorder; final BorderRadius? borderRadius; - final double? height; + final double? minExtent; + final double? maxExtent; final double? startPadding; final double? endPadding; @override Widget build(BuildContext context) { final theme = Theme.of(context); - final appbarHeight = height ?? 64.0; + final appbarHeight = minExtent ?? 64.0; final statusBarHeight = context.statusBarHeight; - final maxHeight = statusBarHeight + 180.0; + final maxHeight = statusBarHeight + (maxExtent ?? 160.0); final minHeight = statusBarHeight + appbarHeight; final offsetHeight = maxHeight - minHeight; final canPop = ModalRoute.of(context)?.canPop ?? false; @@ -102,15 +102,6 @@ class SliverPinnedAppBar extends StatelessWidget { child: Container( decoration: BoxDecoration( color: theme.colorScheme.background, - border: bottomBorder - ? Border( - bottom: BorderSide( - color: display - ? theme.colorScheme.surfaceVariant - : Colors.transparent, - ), - ) - : null, borderRadius: borderRadius, ), padding: EdgeInsetsDirectional.only( diff --git a/pubspec.yaml b/pubspec.yaml index e4530ae..6fa29ac 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: mikan description: mikanani.me -version: 1.1.4+64 +version: 1.1.5+65 publish_to: none @@ -47,6 +47,7 @@ dependencies: easy_refresh: ^3.3.2+1 window_manager: ^0.3.2 dynamic_color: ^1.6.5 + decimal: ^2.3.2 dev_dependencies: flutter_test: