fix(livetv): expand program summaries
This commit is contained in:
@@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user