diff --git a/docs/plans/2026-10-22-favorite-card-synopsis.md b/docs/plans/2026-10-22-favorite-card-synopsis.md new file mode 100644 index 0000000..8923018 --- /dev/null +++ b/docs/plans/2026-10-22-favorite-card-synopsis.md @@ -0,0 +1,20 @@ +# 收藏卡片简介 + +## 目标 + +- 收藏卡片在标题/标签下方显示后端 `synopsis`,最多 2 行并省略溢出。 +- 空白简介不创建文本节点、不占高度,不显示 `null/undefined`。 +- 保持封面 80x106 和现有卡片高度,不引入布局溢出。 + +## 实现 + +1. `_FavoriteCard` 对 synopsis 做 trim 后条件渲染。 +2. 简介使用 `Flexible` 占据标题、标签与底部统计之间的剩余空间, + `maxLines: 2`、`TextOverflow.ellipsis`、次要文案色和字号。 +3. 无简介时继续使用原 Spacer,保持原布局。 +4. widget 测试覆盖有简介、空简介和卡片高度稳定性。 + +## 约束 + +- 不改接口、后端、收藏分页。 +- 不新增依赖,不提交现有 `pubspec.lock`,不启停服务、不操作真机。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 38e1036..eee3f2f 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -698,3 +698,35 @@ $ flutter test --no-pub 解读:有 dramaTitle 时不触发详情请求、点击时才加载详情、空标题兜底、 第 2 页追加去重和 hasMore 行为均通过;全量分析零告警,630 项通过、1 项旧服务 能力探测跳过。`pubspec.lock` 既有改动未提交。 + +## 2026-10-22 收藏卡片简介 + +- 分支:`feature/favorite-card-synopsis` +- 基线:`develop` / `origin/develop` = `9093700` +- 计划:`docs/plans/2026-10-22-favorite-card-synopsis.md` + +### 实现 + +- `_FavoriteCard` 在标题/标签下方条件渲染 `drama.synopsis.trim()`。 +- 简介最多 2 行、`TextOverflow.ellipsis`,使用 `AppColors.muted`、12 号字和 + `1.15` 行高,由 `Flexible` 占据剩余空间,保持封面与卡片原高度。 +- 简介为空或纯空白时不创建文本节点,继续使用原 Spacer,不占高度。 +- 未改接口、后端和收藏分页逻辑。 + +### 门禁 + +```text +$ flutter test --no-pub test/library/favorites_page_test.dart +00:01 +7: All tests passed! + +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:51 +631 ~1: All tests passed! +``` + +解读:有简介时文本最多 2 行并省略,空简介不渲染且卡片高度与正常卡片一致; +全量静态分析零告警,631 项通过、1 项运行中旧后端能力探测跳过。 +`pubspec.lock` 既有改动未提交。 diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index cb1b355..11512c3 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -34,6 +34,9 @@ const double _removeIconSize = 20; const double _statsFontSize = 12; const double _metaFontSize = 11; const double _titleFontSize = 15; +const double _synopsisFontSize = 12; +const double _synopsisLineHeight = 1.15; +const double _synopsisGap = 4; const int _maxTags = 2; const double _emptyListHeight = 360; const double _loadMoreThreshold = 240; @@ -252,6 +255,7 @@ class _FavoriteCard extends StatelessWidget { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final tags = _tags(); + final synopsis = drama.synopsis.trim(); return Container( key: ValueKey('favorite-card-${drama.id}'), decoration: BoxDecoration( @@ -345,7 +349,25 @@ class _FavoriteCard extends StatelessWidget { ), ), ), - const Spacer(), + if (synopsis.isNotEmpty) ...[ + const SizedBox(height: _synopsisGap), + Flexible( + child: Text( + synopsis, + key: ValueKey( + 'favorite-synopsis-${drama.id}', + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: _synopsisFontSize, + height: _synopsisLineHeight, + ), + ), + ), + ] else + const Spacer(), Row( children: [ const Icon( diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart index b1874c8..21579ef 100644 --- a/test/library/favorites_page_test.dart +++ b/test/library/favorites_page_test.dart @@ -32,10 +32,10 @@ import '../support/fake_interaction_repositories.dart'; import '../support/fake_notice_repository.dart'; import '../support/widget_player.dart'; -Drama _card(String id) => Drama( +Drama _card(String id, {String synopsis = 'synopsis'}) => Drama( id: id, title: 'Drama $id', - synopsis: 'synopsis', + synopsis: synopsis, posterUrl: '/files/$id.png', totalEpisodes: 12, ); @@ -73,6 +73,14 @@ void main() { ); expect(title.style?.color, AppColors.onSurface); expect(title.style?.fontSize, 15); + final synopsis = tester.widget( + find.byKey(const Key('favorite-synopsis-signal')), + ); + expect(synopsis.data, 'synopsis'); + expect(synopsis.maxLines, 2); + expect(synopsis.overflow, TextOverflow.ellipsis); + expect(synopsis.style?.color, AppColors.muted); + expect(synopsis.style?.fontSize, 12); expect( tester.getSize(find.byKey(const Key('favorite-cover-signal'))), const Size(80, 106), @@ -100,6 +108,38 @@ void main() { expect(find.byKey(const Key('favorite-card-signal')), findsNothing); }); + testWidgets('should_hide_blank_synopsis_without_changing_card_height', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final longSynopsis = List.filled(12, '很长的剧情简介').join(); + final favorites = FavoriteController( + InMemoryFavoriteRepository( + saved: {'with-synopsis', 'without-synopsis'}, + cards: [ + _card('with-synopsis', synopsis: longSynopsis), + _card('without-synopsis', synopsis: ' '), + ], + ), + ); + + await _pumpPage(tester, auth: auth, favorites: favorites); + + final withSynopsis = tester.widget( + find.byKey(const Key('favorite-synopsis-with-synopsis')), + ); + expect(withSynopsis.maxLines, 2); + expect(withSynopsis.overflow, TextOverflow.ellipsis); + expect( + find.byKey(const Key('favorite-synopsis-without-synopsis')), + findsNothing, + ); + expect( + tester.getSize(find.byKey(const Key('favorite-card-with-synopsis'))), + tester.getSize(find.byKey(const Key('favorite-card-without-synopsis'))), + ); + }); + testWidgets('should_keep_card_and_show_failure_when_remove_fails', ( tester, ) async {