fix(livetv): expand program summaries

This commit is contained in:
edde746
2026-05-22 20:52:21 +02:00
parent 31c01ab3e0
commit 048d22cb99
3 changed files with 201 additions and 24 deletions
+29 -7
View File
@@ -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<FocusNode> _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 = <Widget>[];
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,
),
],
],
+108 -17
View File
@@ -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<bool>? 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<CollapsibleText> createState() => _CollapsibleTextState();
@@ -16,6 +39,47 @@ class CollapsibleText extends StatefulWidget {
class _CollapsibleTextState extends State<CollapsibleText> {
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<CollapsibleText> {
)..layout(maxWidth: constraints.maxWidth);
final overflows = textPainter.didExceedMaxLines;
_reportOverflow(overflows);
if (!overflows) {
textPainter.dispose();
@@ -44,24 +109,50 @@ class _CollapsibleTextState extends State<CollapsibleText> {
}
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),
);
},
);
}
+64
View File
@@ -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<Text>(textFinder).textSpan!.toPlainText();
}