diff --git a/lib/rankings_screen.dart b/lib/rankings_screen.dart index 82d8c76..182b206 100644 --- a/lib/rankings_screen.dart +++ b/lib/rankings_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'app_layout.dart'; +import 'remote_widgets.dart'; import 'catalog_filters.dart'; import 'core_bridge.dart'; import 'detail_screen.dart'; @@ -158,6 +160,7 @@ class _RankingsScreenState extends State { @override Widget build(BuildContext context) { + final television = AppLayout.isTelevision(context); final groups = SourceGroup.fromSources( _boards.map((board) => SourceSite.byId(board.source)).toSet(), ); @@ -287,6 +290,49 @@ class _RankingsScreenState extends State { ? _initialize : () => _load(force: true), ) + : television + ? LayoutBuilder( + builder: (context, constraints) { + final columns = (constraints.maxWidth / 150) + .floor() + .clamp(3, 7); + final width = + (constraints.maxWidth - 24 - (columns - 1) * 12) / + columns; + return RemoteGrid( + key: ValueKey('ranking-${_board!.id}'), + controller: _scroll, + itemKeys: [for (final item in items) item.drama.id], + columns: columns, + itemExtent: DramaTile.extentFor(context, width) + 8, + spacing: 12, + padding: const EdgeInsets.fromLTRB(12, 4, 12, 24), + itemBuilder: (context, index, node, onFocus) => + _rankingTile( + context, + items[index], + focusNode: node, + onFocus: onFocus, + ), + footer: Center( + child: _more + ? const Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator(), + ) + : _hasMore + ? RemoteButton( + label: '加载更多', + onPressed: () => _load(more: true), + ) + : const Padding( + padding: EdgeInsets.all(16), + child: Text('已显示全部榜单'), + ), + ), + ); + }, + ) : RefreshIndicator( onRefresh: () => _load(force: true), child: ListView.builder( @@ -314,104 +360,7 @@ class _RankingsScreenState extends State { ), ); } - final item = items[index]; - return Card( - child: InkWell( - key: ValueKey( - 'rank-${item.rank}-${item.drama.id}', - ), - borderRadius: BorderRadius.circular(12), - onFocusChange: (focused) { - if (focused) { - Scrollable.ensureVisible( - context, - alignment: .4, - ); - } - }, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => DetailScreen( - drama: item.drama, - repository: widget.repository, - store: widget.store, - ), - ), - ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - SizedBox( - width: 36, - child: Text( - '${item.rank}', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w800, - color: item.rank <= 3 - ? Theme.of( - context, - ).colorScheme.primary - : Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ), - ), - SizedBox( - width: 72, - height: 108, - child: DramaCover( - drama: item.drama, - repository: widget.repository, - radius: 8, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - item.drama.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.titleMedium, - ), - if (item.drama.category.isNotEmpty) - Text( - item.drama.category, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (item.metric.isNotEmpty) - Padding( - padding: const EdgeInsets.only( - top: 8, - ), - child: Text( - item.metric, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.primary, - ), - ), - ), - ], - ), - ), - const Icon(Icons.chevron_right_rounded), - ], - ), - ), - ), - ); + return _rankingTile(context, items[index]); }, ), ), @@ -421,4 +370,123 @@ class _RankingsScreenState extends State { ), ); } + + Widget _rankingTile( + BuildContext context, + RankingItem item, { + FocusNode? focusNode, + VoidCallback? onFocus, + }) { + void open() => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => DetailScreen( + drama: item.drama, + repository: widget.repository, + store: widget.store, + ), + ), + ); + if (AppLayout.isTelevision(context)) { + return DramaTile( + key: ValueKey('rank-${item.rank}-${item.drama.id}'), + drama: item.drama, + repository: widget.repository, + focusNode: focusNode, + onFocus: onFocus, + onTap: open, + actions: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(6), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Text( + '第${item.rank}名', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + fontWeight: FontWeight.w700, + fontSize: 14, + ), + ), + ), + ), + subtitle: item.metric.isEmpty ? item.drama.category : item.metric, + ); + } + final card = Card( + child: InkWell( + key: ValueKey('rank-${item.rank}-${item.drama.id}'), + borderRadius: BorderRadius.circular(12), + onFocusChange: (focused) { + if (focused) { + Scrollable.ensureVisible(context, alignment: .4); + } + }, + onTap: open, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + SizedBox( + width: 36, + child: Text( + '${item.rank}', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w800, + color: item.rank <= 3 + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + SizedBox( + width: 72, + height: 108, + child: DramaCover( + drama: item.drama, + repository: widget.repository, + radius: 8, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.drama.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + if (item.drama.category.isNotEmpty) + Text( + item.drama.category, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (item.metric.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + item.metric, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ], + ), + ), + const Icon(Icons.chevron_right_rounded), + ], + ), + ), + ), + ); + return card; + } } diff --git a/test/rankings_tv_test.dart b/test/rankings_tv_test.dart new file mode 100644 index 0000000..fe7584c --- /dev/null +++ b/test/rankings_tv_test.dart @@ -0,0 +1,103 @@ +import 'package:duanju_app/local_store.dart'; +import 'package:duanju_app/models.dart'; +import 'package:duanju_app/ranking_models.dart'; +import 'package:duanju_app/rankings_screen.dart'; +import 'package:duanju_app/remote_widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'fixtures.dart'; +import 'remote_test_helpers.dart'; + +class RankingFixture extends FixtureRepository { + @override + Future> rankingBoards() async => const [ + RankingBoard(id: 'hongguo-hot', source: 'hongguo', name: '总热播榜'), + RankingBoard(id: 'hongguo-real', source: 'hongguo', name: '真人剧榜'), + ]; + + @override + Future rankings( + String board, { + int page = 1, + bool force = false, + }) async => RankingPage( + items: List.generate( + 30, + (i) => RankingItem( + i + 1, + Drama( + id: 'synthetic-$i', + source: 'hongguo', + title: '合成剧${i + 1}', + episodes: 20, + ), + metric: '${1000 - i}热度', + ), + ), + ); +} + +void main() { + testWidgets( + 'TV ranking cards retain visible focus across rows and back to categories', + (tester) async { + tester.view.physicalSize = const Size(960, 540); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + SharedPreferences.setMockInitialValues({}); + final store = LocalStore(await SharedPreferences.getInstance()); + addTearDown(store.dispose); + await tester.pumpWidget( + televisionHost( + child: RankingsScreen( + repository: RankingFixture(), + store: store, + initialGroup: 'hongguo', + ), + ), + ); + await tester.pumpAndSettle(); + final grid = tester.widget(find.byType(RemoteGrid)); + expect(grid.columns, 6); + focusRemote(tester, find.byKey(const ValueKey('category-hongguo-hot'))); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect( + FocusManager.instance.primaryFocus?.debugLabel, + 'remote-synthetic-0', + ); + for (final key in [ + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.arrowDown, + ]) { + await tester.sendKeyEvent(key); + await tester.pumpAndSettle(); + } + expect( + FocusManager.instance.primaryFocus?.debugLabel, + 'remote-synthetic-13', + ); + final focused = find.byKey(const ValueKey('rank-14-synthetic-13')); + expect(focused, findsOneWidget); + expect( + tester.getRect(focused).overlaps(const Rect.fromLTWH(0, 0, 960, 540)), + isTrue, + ); + for (var i = 0; i < 3; i++) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + } + expect( + FocusManager.instance.primaryFocus?.debugLabel, + isNot(startsWith('remote-synthetic-')), + ); + expect(tester.takeException(), isNull); + }, + ); +}