merge: 合并 K48 分类 tab 修正

This commit is contained in:
Zhou Weidong committed 2026-10-20 17:07:33 +08:00
commit 3ce2599754
2 files changed
+177 -63

No files matched your search

+127 -46
View File
@@ -38,8 +38,12 @@ const double _categoryTabHeight = 44;
const double _categoryTabGap = 20;
const double _categorySelectedFontSize = 16;
const double _categoryFontSize = 15;
const double _categoryUnderlineWidth = 20;
const double _categoryUnderlineHeight = 3;
const double _categoryUnderlineStrokeWidth = 2;
const double _stickyHeaderHeight = 44;
const Duration _stickyHeaderTransition = Duration(milliseconds: 160);
const Duration _categoryScrollDuration = Duration(milliseconds: 200);
/// K28-A2 剧场页:搜索、分类/语言 chips 与双列剧库。
class DiscoverPage extends StatefulWidget {
@@ -63,6 +67,10 @@ class DiscoverPage extends StatefulWidget {
class _DiscoverPageState extends State<DiscoverPage> {
late final DiscoverLogic logic;
final ValueNotifier<bool> _headerPinned = ValueNotifier<bool>(false);
final ScrollController _categoryScroll = ScrollController();
final Map<String, GlobalKey> _categoryAnchors = {};
String? _ensuredCategoryId;
bool _categoryRevealScheduled = false;
bool get embedded => widget.callbacks.embedded;
@@ -99,6 +107,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
@override
void dispose() {
logic.scroll.removeListener(_onScroll);
_categoryScroll.dispose();
_headerPinned.dispose();
logic.dispose();
super.dispose();
@@ -277,78 +286,126 @@ class _DiscoverPageState extends State<DiscoverPage> {
}
Widget _categoryTabs(AppLocalizations l10n, CategoryController categories) {
_scheduleSelectedCategoryReveal();
return SizedBox(
key: const Key('theater-category-chips'),
height: _categoryTabHeight,
child: ListView.separated(
child: SingleChildScrollView(
key: const Key('theater-category-scroll'),
controller: _categoryScroll,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 14),
itemCount: categories.categories.length + 1,
separatorBuilder: (_, _) => const SizedBox(width: _categoryTabGap),
itemBuilder: (context, index) {
if (index == 0) {
return _categoryTab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_categoryTab(
key: const Key('theater-category-all'),
anchorKey: _anchorFor('all'),
id: 'all',
label: l10n.all,
selected: logic.selectedCategoryId == null,
onTap: () => logic.selectCategory(null),
);
}
final category = categories.categories[index - 1];
return _categoryTab(
key: Key('theater-category-${category.id}'),
id: category.id,
label: category.name,
selected: logic.selectedCategoryId == category.id,
onTap: () => logic.selectCategory(category.id),
);
},
onTap: () => _selectCategory(null),
),
for (final category in categories.categories) ...[
const SizedBox(width: _categoryTabGap),
_categoryTab(
key: Key('theater-category-${category.id}'),
anchorKey: _anchorFor(category.id),
id: category.id,
label: category.name,
selected: logic.selectedCategoryId == category.id,
onTap: () => _selectCategory(category.id),
),
],
],
),
),
);
}
Widget _categoryTab({
required Key key,
required GlobalKey anchorKey,
required String id,
required String label,
required bool selected,
required VoidCallback onTap,
}) {
return GestureDetector(
key: key,
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
label,
maxLines: 1,
style: TextStyle(
color: selected ? AppColors.onSurface : AppColors.muted,
fontSize: selected
? _categorySelectedFontSize
: _categoryFontSize,
fontWeight: selected ? FontWeight.w800 : FontWeight.w500,
return KeyedSubtree(
key: anchorKey,
child: GestureDetector(
key: key,
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
label,
maxLines: 1,
style: TextStyle(
color: selected ? AppColors.onSurface : AppColors.muted,
fontSize: selected
? _categorySelectedFontSize
: _categoryFontSize,
fontWeight: selected ? FontWeight.w800 : FontWeight.w500,
),
),
),
const SizedBox(height: 4),
Container(
key: Key('theater-category-underline-$id'),
width: 18,
height: 2,
decoration: BoxDecoration(
color: selected ? AppColors.blue : Colors.transparent,
borderRadius: BorderRadius.circular(1),
const SizedBox(height: 4),
CustomPaint(
key: Key('theater-category-underline-$id'),
size: const Size(
_categoryUnderlineWidth,
_categoryUnderlineHeight,
),
painter: CategoryUnderlinePainter(
color: selected ? AppColors.blue : Colors.transparent,
),
),
),
],
],
),
),
);
}
GlobalKey _anchorFor(String id) {
return _categoryAnchors.putIfAbsent(id, GlobalKey.new);
}
void _selectCategory(String? categoryId) {
logic.selectCategory(categoryId);
final id = categoryId ?? 'all';
_ensuredCategoryId = id;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(_revealCategory(id));
});
}
void _scheduleSelectedCategoryReveal() {
final selectedId = logic.selectedCategoryId ?? 'all';
if (_categoryRevealScheduled || _ensuredCategoryId == selectedId) return;
_categoryRevealScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) async {
_categoryRevealScheduled = false;
if (!mounted || (logic.selectedCategoryId ?? 'all') != selectedId) return;
final revealed = await _revealCategory(selectedId);
if (revealed) _ensuredCategoryId = selectedId;
});
}
Future<bool> _revealCategory(String id) async {
final target = _categoryAnchors[id]?.currentContext;
if (target == null) return false;
await Scrollable.ensureVisible(
target,
alignment: 0.5,
duration: _categoryScrollDuration,
curve: Curves.easeOutCubic,
);
return true;
}
Widget _toolbar(AppLocalizations l10n) {
return Padding(
padding: const EdgeInsets.fromLTRB(14, 2, 14, 12),
@@ -501,3 +558,27 @@ class _DiscoverPageState extends State<DiscoverPage> {
],
);
}
/// 分类 tab 的弧形选中线。
class CategoryUnderlinePainter extends CustomPainter {
const CategoryUnderlinePainter({required this.color});
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = _categoryUnderlineStrokeWidth
..strokeCap = StrokeCap.round;
final path = Path()
..moveTo(1, size.height - 1)
..quadraticBezierTo(size.width / 2, 0, size.width - 1, size.height - 1);
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant CategoryUnderlinePainter oldDelegate) =>
color != oldDelegate.color;
}
+50 -17
View File
@@ -12,6 +12,7 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart';
import 'package:fruitshorts/pages/discover/discover_view.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
@@ -274,23 +275,54 @@ void main() {
testWidgets('should_show_category_underline_for_selection', (tester) async {
await _pumpTheater(tester, categoryRepository: _GridCategoryRepository());
Color underline(String id) =>
(tester
.widget<Container>(
find.byKey(Key('theater-category-underline-$id')),
)
.decoration!
as BoxDecoration)
.color!;
CategoryUnderlinePainter underline(String id) =>
tester
.widget<CustomPaint>(
find.byKey(Key('theater-category-underline-$id')),
)
.painter!
as CategoryUnderlinePainter;
expect(underline('all'), AppColors.blue);
expect(underline('7'), Colors.transparent);
expect(
tester
.widget<CustomPaint>(
find.byKey(const Key('theater-category-underline-all')),
)
.size,
const Size(20, 3),
);
expect(underline('all').color, AppColors.blue);
expect(underline('7').color, Colors.transparent);
await tester.tap(find.byKey(const Key('theater-category-7')));
await tester.pumpAndSettle();
expect(underline('all'), Colors.transparent);
expect(underline('7'), AppColors.blue);
expect(underline('all').color, Colors.transparent);
expect(underline('7').color, AppColors.blue);
});
testWidgets('should_scroll_offscreen_category_into_view_when_tapped', (
tester,
) async {
final categories = [
for (var index = 0; index < 12; index++)
ContentCategory(id: '$index', name: 'Long category $index'),
];
await _pumpTheater(
tester,
categoryRepository: _GridCategoryRepository(categories: categories),
);
final target = find.byKey(const Key('theater-category-11'));
expect(tester.getRect(target).right, greaterThan(390));
tester.widget<GestureDetector>(target).onTap!();
await tester.pump();
await tester.pumpAndSettle();
final rect = tester.getRect(target);
expect(rect.left, greaterThanOrEqualTo(0));
expect(rect.right, lessThanOrEqualTo(390));
});
testWidgets('should_use_text_pull_to_refresh', (tester) async {
@@ -344,15 +376,16 @@ Future<void> _pumpTheater(
}
class _GridCategoryRepository implements CategoryRepository {
_GridCategoryRepository({HomeFeed? discoverFeed})
: discoverFeed = discoverFeed ?? _defaultDiscoverFeed;
_GridCategoryRepository({
HomeFeed? discoverFeed,
this.categories = const [ContentCategory(id: '7', name: 'Romance')],
}) : discoverFeed = discoverFeed ?? _defaultDiscoverFeed;
final HomeFeed discoverFeed;
final List<ContentCategory> categories;
@override
Future<List<ContentCategory>> fetchCategories() async => const [
ContentCategory(id: '7', name: 'Romance'),
];
Future<List<ContentCategory>> fetchCategories() async => categories;
@override
Future<HomeFeed> fetchDiscoverFeed() async => discoverFeed;