From 048d22cb997c02968a3fbb1ad07e4a8c0f34d1e8 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 22 May 2026 20:52:21 +0200 Subject: [PATCH] fix(livetv): expand program summaries --- lib/screens/livetv/program_details_sheet.dart | 36 ++++- lib/widgets/collapsible_text.dart | 125 +++++++++++++++--- test/widgets/collapsible_text_test.dart | 64 +++++++++ 3 files changed, 201 insertions(+), 24 deletions(-) create mode 100644 test/widgets/collapsible_text_test.dart diff --git a/lib/screens/livetv/program_details_sheet.dart b/lib/screens/livetv/program_details_sheet.dart index 248af50c..57032b21 100644 --- a/lib/screens/livetv/program_details_sheet.dart +++ b/lib/screens/livetv/program_details_sheet.dart @@ -13,6 +13,7 @@ import '../../services/image_cache_service.dart'; import '../../utils/app_logger.dart'; import '../../utils/formatters.dart'; import '../../widgets/app_icon.dart'; +import '../../widgets/collapsible_text.dart'; import '../../widgets/overlay_sheet.dart'; import '../../widgets/optimized_media_image.dart' show blurArtwork; import 'livetv_recording_actions.dart'; @@ -86,8 +87,10 @@ class _ProgramDetailsSheetContent extends StatefulWidget { class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent> with MountedSetStateMixin { final List _focusNodes = []; + final FocusNode _summaryFocusNode = FocusNode(debugLabel: 'program_sheet_summary'); MediaSubscription? _existingSubscription; bool _checkedMapping = false; + bool _summaryOverflows = false; bool get _canRecord { final client = widget.client; @@ -112,6 +115,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent for (final node in _focusNodes) { node.dispose(); } + _summaryFocusNode.dispose(); super.dispose(); } @@ -155,6 +159,11 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent void _closeSheet() => OverlaySheetController.closeAdaptive(context); + void _handleSummaryOverflowChanged(bool overflows) { + if (_summaryOverflows == overflows) return; + setStateIfMounted(() => _summaryOverflows = overflows); + } + List<_SheetAction> _buildActions() { final program = widget.program; final client = widget.client; @@ -214,7 +223,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent return actions; } - Widget _buildButton(_SheetAction action, int index, int total) { + Widget _buildButton(_SheetAction action, int index, int total, {VoidCallback? onNavigateUp}) { final node = _focusNodes[index]; final child = action.style == _ActionStyle.filled ? FilledButton.icon( @@ -235,6 +244,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent onPressed: action.onPressed, onNavigateLeft: index > 0 ? () => _focusButton(index - 1) : null, onNavigateRight: index < total - 1 ? () => _focusButton(index + 1) : null, + onNavigateUp: onNavigateUp, onBack: _closeSheet, child: child, ); @@ -247,14 +257,24 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent final channel = widget.channel; final actions = _buildActions(); _ensureFocusNodes(actions.length); + final summary = program.summary; + final hasSummary = summary != null && summary.isNotEmpty; + final canFocusSummary = hasSummary && _summaryOverflows; final buttons = []; for (var i = 0; i < actions.length; i++) { if (i > 0) buttons.add(const SizedBox(width: 8)); - buttons.add(_buildButton(actions[i], i, actions.length)); + buttons.add( + _buildButton( + actions[i], + i, + actions.length, + onNavigateUp: canFocusSummary ? () => _summaryFocusNode.requestFocus() : null, + ), + ); } - return Padding( + return SingleChildScrollView( padding: const EdgeInsets.all(20), child: Column( mainAxisSize: MainAxisSize.min, @@ -311,13 +331,15 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent ].join(' ยท '), style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), - if (program.summary != null && program.summary!.isNotEmpty) ...[ + if (hasSummary) ...[ const SizedBox(height: 12), - Text( - program.summary!, + CollapsibleText( + text: summary, style: theme.textTheme.bodyMedium, maxLines: 4, - overflow: TextOverflow.ellipsis, + focusNode: _summaryFocusNode, + onOverflowChanged: _handleSummaryOverflowChanged, + onNavigateDown: buttons.isNotEmpty ? () => _focusButton(0) : null, ), ], ], diff --git a/lib/widgets/collapsible_text.dart b/lib/widgets/collapsible_text.dart index 81aeb057..bf79f47e 100644 --- a/lib/widgets/collapsible_text.dart +++ b/lib/widgets/collapsible_text.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import '../focus/dpad_navigator.dart'; +import '../focus/input_mode_tracker.dart'; +import '../focus/key_event_utils.dart'; import 'clickable_cursor.dart'; class CollapsibleText extends StatefulWidget { @@ -7,8 +10,28 @@ class CollapsibleText extends StatefulWidget { final int maxLines; final TextStyle? style; final bool small; + final FocusNode? focusNode; + final VoidCallback? onNavigateUp; + final VoidCallback? onNavigateDown; + final VoidCallback? onNavigateLeft; + final VoidCallback? onNavigateRight; + final ValueChanged? onOverflowChanged; + final bool skipTraversal; - const CollapsibleText({super.key, required this.text, this.maxLines = 4, this.style, this.small = false}); + const CollapsibleText({ + super.key, + required this.text, + this.maxLines = 4, + this.style, + this.small = false, + this.focusNode, + this.onNavigateUp, + this.onNavigateDown, + this.onNavigateLeft, + this.onNavigateRight, + this.onOverflowChanged, + this.skipTraversal = true, + }); @override State createState() => _CollapsibleTextState(); @@ -16,6 +39,47 @@ class CollapsibleText extends StatefulWidget { class _CollapsibleTextState extends State { bool _expanded = false; + bool? _lastReportedOverflow; + + void _toggleExpanded() => setState(() => _expanded = !_expanded); + + void _reportOverflow(bool overflows) { + if (_lastReportedOverflow == overflows) return; + _lastReportedOverflow = overflows; + if (widget.onOverflowChanged == null) return; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _lastReportedOverflow != overflows) return; + widget.onOverflowChanged?.call(overflows); + }); + } + + KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { + final selectResult = handleOneShotSelect(event, _toggleExpanded); + if (selectResult != KeyEventResult.ignored) return selectResult; + + if (!event.isActionable) return KeyEventResult.ignored; + + final key = event.logicalKey; + if (key.isUpKey && widget.onNavigateUp != null) { + widget.onNavigateUp!(); + return KeyEventResult.handled; + } + if (key.isDownKey && widget.onNavigateDown != null) { + widget.onNavigateDown!(); + return KeyEventResult.handled; + } + if (key.isLeftKey && widget.onNavigateLeft != null) { + widget.onNavigateLeft!(); + return KeyEventResult.handled; + } + if (key.isRightKey && widget.onNavigateRight != null) { + widget.onNavigateRight!(); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } @override Widget build(BuildContext context) { @@ -30,6 +94,7 @@ class _CollapsibleTextState extends State { )..layout(maxWidth: constraints.maxWidth); final overflows = textPainter.didExceedMaxLines; + _reportOverflow(overflows); if (!overflows) { textPainter.dispose(); @@ -44,24 +109,50 @@ class _CollapsibleTextState extends State { } textPainter.dispose(); - return ClickableCursor( - child: GestureDetector( - onTap: () => setState(() => _expanded = !_expanded), - child: Text.rich( - TextSpan( - children: [ - TextSpan(text: displayText, style: style), - if (!_expanded) - WidgetSpan( - alignment: widget.small ? PlaceholderAlignment.baseline : PlaceholderAlignment.middle, - baseline: widget.small ? TextBaseline.alphabetic : null, - child: _buildBadge(context), - ), - ], - ), - ), + Widget result = Text.rich( + TextSpan( + children: [ + TextSpan(text: displayText, style: style), + if (!_expanded) + WidgetSpan( + alignment: widget.small ? PlaceholderAlignment.baseline : PlaceholderAlignment.middle, + baseline: widget.small ? TextBaseline.alphabetic : null, + child: _buildBadge(context), + ), + ], ), ); + + final focusNode = widget.focusNode; + if (focusNode != null) { + result = Focus( + focusNode: focusNode, + skipTraversal: widget.skipTraversal, + onKeyEvent: _handleKeyEvent, + child: ListenableBuilder( + listenable: focusNode, + builder: (context, child) { + final showFocus = focusNode.hasFocus && InputModeTracker.isKeyboardMode(context); + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: showFocus + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12) + : Colors.transparent, + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + child: child, + ); + }, + child: result, + ), + ); + } + + return ClickableCursor( + child: GestureDetector(onTap: _toggleExpanded, child: result), + ); }, ); } diff --git a/test/widgets/collapsible_text_test.dart b/test/widgets/collapsible_text_test.dart new file mode 100644 index 00000000..cc30a5ff --- /dev/null +++ b/test/widgets/collapsible_text_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/widgets/collapsible_text.dart'; + +void main() { + testWidgets('select expands overflowing focused text', (tester) async { + final focusNode = FocusNode(debugLabel: 'test_collapsible_text'); + addTearDown(focusNode.dispose); + + const text = + 'This program summary is intentionally long enough to overflow a narrow details sheet and require expansion.'; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 120, + child: CollapsibleText(text: text, maxLines: 1, focusNode: focusNode), + ), + ), + ), + ), + ); + + expect(_collapsiblePlainText(tester), isNot(text)); + + focusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(_collapsiblePlainText(tester), text); + expect(focusNode.skipTraversal, isTrue); + }); + + testWidgets('reports whether text overflows', (tester) async { + bool? overflows; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 120, + child: CollapsibleText( + text: 'This summary is long enough to overflow in this narrow box.', + maxLines: 1, + onOverflowChanged: (value) => overflows = value, + ), + ), + ), + ), + ); + await tester.pump(); + + expect(overflows, isTrue); + }); +} + +String _collapsiblePlainText(WidgetTester tester) { + final textFinder = find.byWidgetPredicate((widget) => widget is Text && widget.textSpan != null); + return tester.widget(textFinder).textSpan!.toPlainText(); +}