fix: restore focus and interaction behavior

This commit is contained in:
edde746
2026-07-13 17:00:50 +02:00
parent 822657972d
commit 40792e2779
28 changed files with 1624 additions and 120 deletions
+3 -1
View File
@@ -29,6 +29,7 @@ class FocusableAction {
final IconData icon;
final Color? iconColor;
final double iconFill;
final double iconSize;
final String? debugLabel;
final FocusNode? focusNode;
@@ -43,6 +44,7 @@ class FocusableAction {
this.icon = Icons.circle,
this.iconColor,
this.iconFill = 1.0,
this.iconSize = 24,
this.debugLabel,
this.focusNode,
this.autofocus = false,
@@ -246,7 +248,7 @@ class FocusableActionBarState extends State<FocusableActionBar> {
child:
action.child ??
IconButton(
icon: AppIcon(action.icon, fill: action.iconFill, color: action.iconColor),
icon: AppIcon(action.icon, size: action.iconSize, fill: action.iconFill, color: action.iconColor),
tooltip: action.tooltip,
onPressed: action.onPressed,
),
+3 -2
View File
@@ -70,6 +70,7 @@ class _FocusableSliderState extends State<FocusableSlider> {
@override
Widget build(BuildContext context) {
final sliderTheme = SliderTheme.of(context);
return FocusableWrapper(
focusNode: widget.focusNode,
autofocus: widget.autofocus,
@@ -79,8 +80,8 @@ class _FocusableSliderState extends State<FocusableSlider> {
onFocusChange: (focused) => setState(() => _isFocused = focused),
onKeyEvent: _handleKeyEvent,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
overlayShape: const RoundSliderOverlayShape(overlayRadius: 0),
data: sliderTheme.copyWith(
overlayShape: sliderTheme.overlayShape ?? const RoundSliderOverlayShape(overlayRadius: 0),
thumbSize: WidgetStatePropertyAll(
(!InputModeTracker.isKeyboardMode(context) || _isFocused) ? const Size(4, 20) : Size.zero,
),
+35 -1
View File
@@ -55,6 +55,30 @@ class GuideTab extends StatefulWidget {
State<GuideTab> createState() => GuideTabState();
}
@visibleForTesting
({String channelScopeKey, ({String kind, String value})? programId, int? beginsAt, int? endsAt}) guideAiringIdentity(
LiveTvChannel channel,
LiveTvProgram program,
) {
({String kind, String value})? programId;
if (program.ratingKey case final ratingKey? when ratingKey.isNotEmpty) {
programId = (kind: 'ratingKey', value: ratingKey);
} else if (program.guid case final guid? when guid.isNotEmpty) {
programId = (kind: 'guid', value: guid);
} else if (program.key case final key? when key.isNotEmpty) {
programId = (kind: 'key', value: key);
}
// Keep the slot even with an ID so repeated airings cannot inherit each
// other's hold; with no ID, channel scope plus timing is the fallback.
return (
channelScopeKey: liveTvChannelScopeKey(channel),
programId: programId,
beginsAt: program.beginsAt,
endsAt: program.endsAt,
);
}
enum _GuideZone { timeNav, grid }
sealed class _GuideRow {
@@ -578,9 +602,19 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
final target = _focusedProgramTarget();
if (target == null) return KeyEventResult.ignored;
final ownerChannelIndex = _gridChannelIndex;
final targetIdentity = guideAiringIdentity(target.channel, target.program);
return _programSelectController.handleKeyEvent(
event,
isOwnerActive: () => mounted && _focusedProgramTarget() == target,
isOwnerActive: () {
if (!mounted || _focusZone != _GuideZone.grid || _gridColumn != 1 || _gridChannelIndex != ownerChannelIndex) {
return false;
}
final activeTarget = _focusedProgramTarget();
return activeTarget != null &&
guideAiringIdentity(activeTarget.channel, activeTarget.program) == targetIdentity;
},
onShortPress: () => _activateProgram(target.channel, target.program),
onLongPress: () {
_programSelectController.reset();
+15 -4
View File
@@ -2149,7 +2149,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
if (!widget.isOffline) _ratingChipFocusNode.requestFocus();
}
/// Focus the first available section below the primary action row.
/// Focus the overview, or the first available content section when there is no overview.
void _focusBelowActionRow() {
final metadata = _fullMetadata ?? _metadata;
@@ -2158,13 +2158,20 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return;
}
// DOWN order: overview → seasons → cast → extras
if (!PlatformDetector.isTV() && metadata.summary != null && metadata.summary!.isNotEmpty) {
if (metadata.summary != null && metadata.summary!.isNotEmpty) {
_overviewFocusNode.requestFocus();
_scrollSectionIntoView(_overviewSectionKey);
return;
}
_focusBelowOverview();
}
/// Focus the first available content section after the overview.
void _focusBelowOverview() {
final metadata = _fullMetadata ?? _metadata;
// DOWN order: season tabs → episodes → cast → extras → related hubs → info rows.
if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) {
// Focus the selected season tab chip
_seasonTabFocusNodes[_selectedSeasonIndex].requestFocus();
@@ -2194,6 +2201,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
_relatedHubKeys.first.currentState?.requestFocusFromMemory();
return;
}
if (_hasInfoRows) {
_focusInfoRows();
}
}
/// Get the responsive card width used by seasons/extras/cast rows.
@@ -3155,7 +3166,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
);
_playButtonFocusNode.requestFocus();
},
onNavigateDown: _focusBelowActionRow,
onNavigateDown: _focusBelowOverview,
onNavigateLeft: () {},
onNavigateRight: () {},
),
+3
View File
@@ -54,18 +54,21 @@ class QueueSheet extends StatelessWidget {
FocusableAction(
icon: Symbols.shuffle_rounded,
iconColor: service.shuffled ? colorScheme.primary : tk.textMuted,
iconSize: 20,
tooltip: t.common.shuffle,
onPressed: service.toggleShuffle,
),
FocusableAction(
icon: repeatModeIcon(service.repeatMode),
iconColor: service.repeatMode == MusicRepeatMode.off ? tk.textMuted : colorScheme.primary,
iconSize: 20,
tooltip: repeatModeLabel(service.repeatMode),
onPressed: () => service.setRepeatMode(nextRepeatMode(service.repeatMode)),
),
FocusableAction(
icon: Symbols.clear_all_rounded,
iconColor: tk.textMuted,
iconSize: 20,
tooltip: t.music.clearQueue,
onPressed: service.clearUpcoming,
),
@@ -113,14 +113,15 @@ class _PlayerTile extends StatelessWidget {
trailing: Row(
mainAxisSize: .min,
children: [
FocusableButton(
onPressed: () => svc.removeCustomExternalPlayer(player.id),
autoScroll: false,
child: IconButton(
icon: const AppIcon(Symbols.delete_rounded, fill: 1, size: 20),
if (isCustom)
FocusableButton(
onPressed: () => svc.removeCustomExternalPlayer(player.id),
autoScroll: false,
child: IconButton(
icon: const AppIcon(Symbols.delete_rounded, fill: 1, size: 20),
onPressed: () => svc.removeCustomExternalPlayer(player.id),
),
),
),
AppIcon(
isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
fill: 1,
@@ -113,13 +113,12 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
border: Border.fromBorderSide(BorderSide(color: Theme.of(context).dividerColor)),
borderRadius: const BorderRadius.all(Radius.circular(6)),
),
child: hasShortcut
? HotKeyRecorder(
initalHotKey: _recordedHotKey,
enabled: _isCapturing,
onHotKeyRecorded: _handleHotKeyRecorded,
)
: Text(recordLabel),
child: HotKeyRecorder(
initalHotKey: _recordedHotKey,
enabled: _isCapturing,
placeholder: Text(recordLabel),
onHotKeyRecorded: _handleHotKeyRecorded,
),
),
),
),
@@ -1,6 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/foundation.dart' show ValueListenable, visibleForTesting;
import 'package:flutter/widgets.dart';
import 'package:os_media_controls/os_media_controls.dart';
@@ -66,10 +66,12 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
this._mediaControlsFactory = MediaControlsManager.new,
this._completedConfirmDelay = const Duration(milliseconds: 400),
PlaybackCoordinator? coordinator,
@visibleForTesting Future<void> Function(double)? volumePersistenceWriter,
}) : assert(resolver != null || database != null, 'database is required to build the default resolver'),
_serverManager = serverManager,
_resolver = resolver ?? ServerMusicSourceResolver(serverManager: serverManager, database: database!),
_coordinator = coordinator ?? PlaybackCoordinator.instance {
_coordinator = coordinator ?? PlaybackCoordinator.instance,
_volumePersistenceWriter = volumePersistenceWriter ?? _writePersistedVolume {
_coordinator.registerMusicSession(stopAndDispose: _stopForVideoClaim);
// tvOS has no background-audio session in v1 — pause on backgrounding so
// audio doesn't play over other apps / the home screen. Other platforms
@@ -96,6 +98,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
final Player Function() _audioPlayerFactory;
final MediaControlsManager Function() _mediaControlsFactory;
final PlaybackCoordinator _coordinator;
final Future<void> Function(double) _volumePersistenceWriter;
final MusicQueueController _queue = MusicQueueController();
@@ -104,6 +107,10 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
/// volume when settings aren't bootstrapped (tests).
double _volume = SettingsService.instanceOrNull?.read(SettingsService.musicVolume) ?? 100.0;
late final ValueNotifier<double> _volumeNotifier = ValueNotifier<double>(_volume);
// One settings write stays in flight while rapid updates replace the single
// pending slot. The drain intentionally survives service disposal.
double? _pendingVolumeWrite;
Future<void>? _volumeWriteDrain;
Player? _player;
final List<StreamSubscription<Object?>> _playerSubs = [];
@@ -850,14 +857,58 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
@override
Future<void> setVolume(double volume, {bool persist = true}) async {
final clamped = volume.clamp(0.0, 100.0);
Future<void>? playerUpdate;
if (clamped != _volume) {
_volume = clamped;
_volumeNotifier.value = clamped;
await _player?.setVolume(clamped);
playerUpdate = _player?.setVolume(clamped);
}
if (persist) {
final settings = SettingsService.instanceOrNull;
if (settings != null) await settings.write(SettingsService.musicVolume, clamped);
final persistence = persist ? _persistVolume(clamped) : null;
if (playerUpdate != null && persistence != null) {
await Future.wait([playerUpdate, persistence]);
} else {
await playerUpdate;
await persistence;
}
}
static Future<void> _writePersistedVolume(double volume) async {
final settings = SettingsService.instanceOrNull;
if (settings != null) await settings.write(SettingsService.musicVolume, volume);
}
Future<void> _persistVolume(double volume) {
_pendingVolumeWrite = volume;
final activeDrain = _volumeWriteDrain;
if (activeDrain != null) return activeDrain;
final completer = Completer<void>();
_volumeWriteDrain = completer.future;
unawaited(_drainVolumeWrites(completer));
return completer.future;
}
Future<void> _drainVolumeWrites(Completer<void> completer) async {
Object? firstError;
StackTrace? firstStackTrace;
while (_pendingVolumeWrite != null) {
final volume = _pendingVolumeWrite!;
_pendingVolumeWrite = null;
try {
await _volumePersistenceWriter(volume);
} catch (error, stackTrace) {
firstError ??= error;
firstStackTrace ??= stackTrace;
}
}
_volumeWriteDrain = null;
if (firstError != null) {
completer.completeError(firstError, firstStackTrace);
} else {
completer.complete();
}
}
@@ -268,8 +268,10 @@ class _SessionMenuSheet extends StatelessWidget {
isDestructive: true,
);
if (!confirmed || !context.mounted) return;
OverlaySheetController.closeAdaptive(context);
if (!confirmed) return;
if (context.mounted) {
OverlaySheetController.closeAdaptive(context);
}
unawaited(provider.leaveSession());
onLeaveSession?.call();
}
+40 -3
View File
@@ -17,6 +17,10 @@ class CollapsibleText extends StatefulWidget {
final ValueChanged<bool>? onOverflowChanged;
final bool skipTraversal;
/// Hides this widget's expand/collapse label and semantic tap action while
/// preserving the semantics of the text itself.
final bool suppressExpandSemantics;
const CollapsibleText({
super.key,
required this.text,
@@ -30,6 +34,7 @@ class CollapsibleText extends StatefulWidget {
this.onNavigateRight,
this.onOverflowChanged,
this.skipTraversal = true,
this.suppressExpandSemantics = false,
});
@override
@@ -70,7 +75,31 @@ class _CollapsibleTextState extends State<CollapsibleText> {
if (!overflows) {
textPainter.dispose();
return Text(widget.text, style: style);
Widget result = Text(widget.text, style: style);
final hasFocusBehavior =
widget.focusNode != null ||
widget.onNavigateUp != null ||
widget.onNavigateDown != null ||
widget.onNavigateLeft != null ||
widget.onNavigateRight != null;
if (!hasFocusBehavior) return result;
result = FocusableWrapper(
focusNode: widget.focusNode,
onNavigateUp: widget.onNavigateUp,
onNavigateDown: widget.onNavigateDown,
onNavigateLeft: widget.onNavigateLeft,
onNavigateRight: widget.onNavigateRight,
descendantsAreFocusable: false,
disableScale: true,
useBackgroundFocus: true,
borderRadius: 8,
child: result,
);
if (widget.skipTraversal) {
result = ExcludeFocusTraversal(child: result);
}
return result;
}
String displayText = widget.text;
@@ -102,7 +131,11 @@ class _CollapsibleTextState extends State<CollapsibleText> {
onNavigateDown: widget.onNavigateDown,
onNavigateLeft: widget.onNavigateLeft,
onNavigateRight: widget.onNavigateRight,
semanticLabel: _expanded ? t.accessibility.collapseText : t.accessibility.expandText,
semanticLabel: widget.suppressExpandSemantics
? null
: _expanded
? t.accessibility.collapseText
: t.accessibility.expandText,
descendantsAreFocusable: false,
disableScale: true,
useBackgroundFocus: true,
@@ -114,7 +147,11 @@ class _CollapsibleTextState extends State<CollapsibleText> {
}
return ClickableCursor(
child: GestureDetector(onTap: _toggleExpanded, child: result),
child: GestureDetector(
onTap: _toggleExpanded,
excludeFromSemantics: widget.suppressExpandSemantics,
child: result,
),
);
},
);
+1
View File
@@ -307,6 +307,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
text: episode.summary!,
maxLines: 3,
small: true,
suppressExpandSemantics: true,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, height: 1.3),
+20 -6
View File
@@ -1,15 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../focus/dpad_navigator.dart';
import '../models/hotkey_model.dart';
/// Captures a key combination from the user and calls [onHotKeyRecorded].
class HotKeyRecorder extends StatefulWidget {
const HotKeyRecorder({super.key, this.initalHotKey, required this.onHotKeyRecorded, this.enabled = true});
const HotKeyRecorder({
super.key,
this.initalHotKey,
required this.onHotKeyRecorded,
this.enabled = true,
this.placeholder,
});
final HotKey? initalHotKey;
final ValueChanged<HotKey> onHotKeyRecorded;
final bool enabled;
final Widget? placeholder;
@override
State<HotKeyRecorder> createState() => _HotKeyRecorderState();
@@ -52,16 +60,22 @@ class _HotKeyRecorderState extends State<HotKeyRecorder> {
.where((m) => !m.physicalKeys.contains(key))
.toList();
setState(() {
_hotKey = HotKey(key: key, modifiers: modifiers.isNotEmpty ? modifiers : null);
});
widget.onHotKeyRecorded(_hotKey!);
final hotKey = HotKey(key: key, modifiers: modifiers.isNotEmpty ? modifiers : null);
setState(() => _hotKey = hotKey);
final isModifierOnly = HotKeyModifier.values.any((modifier) => modifier.physicalKeys.contains(key));
if (isModifierOnly) return true;
if (keyEvent.logicalKey.isSelectKey) {
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
}
widget.onHotKeyRecorded(hotKey);
return true;
}
@override
Widget build(BuildContext context) {
if (_hotKey == null) return const SizedBox.shrink();
if (_hotKey == null) return widget.placeholder ?? const SizedBox.shrink();
return HotKeyVirtualView(hotKey: _hotKey!);
}
}
+1 -5
View File
@@ -508,11 +508,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
for (final item in menuItems)
AppMenuItem<String>(value: item.value, icon: item.icon, label: item.label, destructive: item.isDestructive),
],
closeOnSelected: false,
onSelected: (value) {
OverlaySheetController.popAdaptive(context, value);
widget.onLibraryMenuAction(value, library);
},
onSelected: (value) => widget.onLibraryMenuAction(value, library),
),
);
}
+61 -60
View File
@@ -274,35 +274,35 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix
color: tk.surface,
clipBehavior: Clip.antiAlias,
borderRadius: BorderRadius.circular(tk.radiusLg),
child: SizedBox(
height: _MusicMiniPlayerOverlayState._cardHeight,
child: Stack(
children: [
const Positioned.fill(child: _MiniPlayerProgress()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
Expanded(
child: FocusableWrapper(
focusNode: _detailsFocusNode,
onSelect: () => unawaited(openNowPlaying(context)),
enableLongPress: true,
onLongPress: showContextMenuFromTap,
onNavigateRight: () => _transportKey.currentState?.requestFocusOnFirst(),
semanticLabel: widget.track.title,
descendantsAreFocusable: false,
disableScale: true,
useBackgroundFocus: true,
borderRadius: tk.radiusLg,
child: InkWell(
canRequestFocus: false,
mouseCursor: SystemMouseCursors.click,
onTap: () => unawaited(openNowPlaying(context)),
onTapDown: storeTapPosition,
onLongPress: showContextMenuFromTap,
onSecondaryTapDown: storeTapPosition,
onSecondaryTap: showContextMenuFromTap,
child: InkWell(
canRequestFocus: false,
mouseCursor: SystemMouseCursors.click,
onTap: () => unawaited(openNowPlaying(context)),
onTapDown: storeTapPosition,
onLongPress: showContextMenuFromTap,
onSecondaryTapDown: storeTapPosition,
onSecondaryTap: showContextMenuFromTap,
child: SizedBox(
height: _MusicMiniPlayerOverlayState._cardHeight,
child: Stack(
children: [
const Positioned.fill(child: _MiniPlayerProgress()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
Expanded(
child: FocusableWrapper(
focusNode: _detailsFocusNode,
onSelect: () => unawaited(openNowPlaying(context)),
enableLongPress: true,
onLongPress: showContextMenu,
onNavigateRight: () => _transportKey.currentState?.requestFocusOnFirst(),
semanticLabel: widget.track.title,
descendantsAreFocusable: false,
disableScale: true,
useBackgroundFocus: true,
borderRadius: tk.radiusLg,
child: Row(
children: [
ClipRRect(
@@ -342,43 +342,44 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix
),
),
),
),
FocusableActionBar(
key: _transportKey,
onNavigateLeft: _detailsFocusNode.requestFocus,
actions: [
if (widget.desktop)
FocusableActionBar(
key: _transportKey,
onNavigateLeft: _detailsFocusNode.requestFocus,
actions: [
if (widget.desktop)
FocusableAction(
icon: Symbols.skip_previous_rounded,
iconColor: tk.text,
tooltip: t.music.previousTrack,
onPressed: () => unawaited(service.previous()),
),
FocusableAction(
icon: Symbols.skip_previous_rounded,
icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
iconColor: tk.text,
tooltip: t.music.previousTrack,
onPressed: () => unawaited(service.previous()),
tooltip: isPlaying ? t.common.pause : t.common.play,
onPressed: () => unawaited(service.togglePlayPause()),
),
FocusableAction(
icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
iconColor: tk.text,
tooltip: isPlaying ? t.common.pause : t.common.play,
onPressed: () => unawaited(service.togglePlayPause()),
),
FocusableAction(
icon: Symbols.skip_next_rounded,
iconColor: tk.text,
tooltip: t.music.nextTrack,
onPressed: () => unawaited(service.next()),
),
if (widget.desktop)
FocusableAction(
icon: Symbols.close_rounded,
iconColor: tk.textMuted,
tooltip: t.music.stopPlayback,
onPressed: widget.onDismissed,
icon: Symbols.skip_next_rounded,
iconColor: tk.text,
tooltip: t.music.nextTrack,
onPressed: () => unawaited(service.next()),
),
],
),
],
if (widget.desktop)
FocusableAction(
icon: Symbols.close_rounded,
iconColor: tk.textMuted,
iconSize: 20,
tooltip: t.music.stopPlayback,
onPressed: widget.onDismissed,
),
],
),
],
),
),
),
],
],
),
),
),
);
+18 -3
View File
@@ -157,8 +157,13 @@ class OverlaySheetController {
/// Push a sub-page using the overlay system if available, otherwise fall
/// back to [showModalBottomSheet]. Returns the result from the page.
///
/// Presentation options apply only to the modal fallback. A hosted push
/// retains the root sheet's presentation and changes only its page content.
/// When a hosted sheet is already open, this pushes a nested page and
/// retains the root sheet's presentation. When a host is available but
/// idle, this opens [builder] as its root sheet using the supplied hosted
/// presentation options. Without a host, the modal fallback is used.
///
/// [isScrollControlled] applies only to the modal fallback; hosted sheets
/// use their explicit or default constraints.
static Future<T?> pushAdaptive<T>(
BuildContext context, {
required WidgetBuilder builder,
@@ -171,7 +176,17 @@ class OverlaySheetController {
}) async {
final controller = maybeOf(context);
if (controller != null) {
return controller.push<T>(builder: builder, initialFocusNode: initialFocusNode);
if (controller.isOpen) {
return controller.push<T>(builder: builder, initialFocusNode: initialFocusNode);
}
return controller.show<T>(
builder: builder,
constraints: constraints,
backgroundColor: backgroundColor,
barrierDismissible: barrierDismissible,
initialFocusNode: initialFocusNode,
showDragHandle: showDragHandle,
);
}
final effectiveConstraints =
constraints ??
+28 -5
View File
@@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/focusable_slider.dart';
void main() {
testWidgets('D-pad adjustment reports a complete persisted change', (tester) async {
testWidgets('each D-pad arrow reports a complete persisted change', (tester) async {
final focusNode = FocusNode(debugLabel: 'slider');
addTearDown(focusNode.dispose);
final starts = <double>[];
@@ -38,11 +38,34 @@ void main() {
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.pump();
expect(starts, [0.0]);
expect(changes, [1.0]);
expect(ends, [1.0]);
expect(value, 1.0);
expect(starts, [0.0, 1.0]);
expect(changes, [1.0, 0.0]);
expect(ends, [1.0, 0.0]);
expect(value, 0.0);
});
testWidgets('preserves an inherited 12px slider overlay', (tester) async {
const inheritedOverlay = RoundSliderOverlayShape(overlayRadius: 12);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SliderTheme(
data: const SliderThemeData(overlayShape: inheritedOverlay),
child: FocusableSlider(value: 0, onChanged: (_) {}),
),
),
),
);
final sliderContext = tester.element(find.byType(Slider));
final overlay = SliderTheme.of(sliderContext).overlayShape;
expect(overlay, same(inheritedOverlay));
expect(overlay!.getPreferredSize(false, false), const Size.square(24));
});
testWidgets('SELECT invokes the slider action once', (tester) async {
+96
View File
@@ -0,0 +1,96 @@
import 'package:fake_async/fake_async.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/dpad_navigator.dart';
import 'package:plezy/focus/dpad_select_long_press_controller.dart';
import 'package:plezy/models/livetv_channel.dart';
import 'package:plezy/models/livetv_program.dart';
import 'package:plezy/screens/livetv/tabs/guide_tab.dart';
const _selectDown = KeyDownEvent(
physicalKey: PhysicalKeyboardKey.enter,
logicalKey: LogicalKeyboardKey.enter,
timeStamp: Duration.zero,
);
LiveTvChannel _channel({String key = 'channel/7'}) =>
LiveTvChannel(key: key, identifier: 'station-7', callSign: 'SEVEN', serverId: 'server-a', liveDvrKey: 'dvr-a');
LiveTvProgram _program({String ratingKey = 'program/42', int beginsAt = 1_800_000_000, int endsAt = 1_800_003_600}) =>
LiveTvProgram(
ratingKey: ratingKey,
title: 'Evening News',
beginsAt: beginsAt,
endsAt: endsAt,
channelIdentifier: 'station-7',
serverId: 'server-a',
liveDvrKey: 'dvr-a',
);
void main() {
tearDown(SelectKeyUpSuppressor.clearSuppression);
test('SELECT hold survives equivalent fresh guide objects and opens details once', () {
fakeAsync((async) {
final controller = DpadSelectLongPressController();
var focusedChannel = _channel();
var focusedProgram = _program();
final pressedIdentity = guideAiringIdentity(focusedChannel, focusedProgram);
var detailsOpened = 0;
controller.handleKeyEvent(
_selectDown,
isOwnerActive: () => guideAiringIdentity(focusedChannel, focusedProgram) == pressedIdentity,
onShortPress: () {},
onLongPress: () {
controller.reset();
detailsOpened++;
},
);
async.elapse(const Duration(milliseconds: 250));
final replacementChannel = _channel();
final replacementProgram = _program();
expect(identical(replacementChannel, focusedChannel), isFalse);
expect(identical(replacementProgram, focusedProgram), isFalse);
focusedChannel = replacementChannel;
focusedProgram = replacementProgram;
async.elapse(const Duration(milliseconds: 249));
expect(detailsOpened, 0);
async.elapse(const Duration(milliseconds: 1));
expect(detailsOpened, 1);
async.elapse(const Duration(seconds: 1));
expect(detailsOpened, 1);
controller.dispose();
});
});
test('SELECT hold does not open details after focus moves to a different airing', () {
fakeAsync((async) {
final controller = DpadSelectLongPressController();
final focusedChannel = _channel();
var focusedProgram = _program();
final pressedIdentity = guideAiringIdentity(focusedChannel, focusedProgram);
var detailsOpened = 0;
controller.handleKeyEvent(
_selectDown,
isOwnerActive: () => guideAiringIdentity(focusedChannel, focusedProgram) == pressedIdentity,
onShortPress: () {},
onLongPress: () => detailsOpened++,
);
async.elapse(const Duration(milliseconds: 250));
focusedProgram = _program(beginsAt: 1_800_003_600, endsAt: 1_800_007_200);
expect(guideAiringIdentity(focusedChannel, focusedProgram), isNot(pressedIdentity));
async.elapse(const Duration(milliseconds: 250));
expect(detailsOpened, 0);
async.elapse(const Duration(seconds: 1));
expect(detailsOpened, 0);
controller.dispose();
});
});
}
+73 -1
View File
@@ -36,6 +36,7 @@ import 'package:plezy/utils/layout_constants.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/utils/watch_state_notifier.dart';
import 'package:plezy/widgets/collapsible_text.dart';
import 'package:plezy/widgets/episode_card.dart';
import 'package:plezy/widgets/tv_browse_rail.dart';
import 'package:provider/provider.dart';
@@ -657,11 +658,12 @@ void main() {
});
group('watch state freshness (phone layout)', () {
MediaItem buildShow() => testMediaItem(
MediaItem buildShow({String? summary}) => testMediaItem(
id: 'show_1',
backend: MediaBackend.jellyfin,
kind: MediaKind.show,
title: 'The Show',
summary: summary,
leafCount: 4,
viewedLeafCount: 0,
serverId: 'server_1',
@@ -784,6 +786,76 @@ void main() {
await tester.pump(const Duration(milliseconds: 100));
}
_FakeMediaServerClient singleSeasonClient(MediaItem show) {
final season = buildSeason(show, 1);
return _FakeMediaServerClient(
show: show,
childrenByParent: {
show.id: [season],
season.id: [buildEpisode(show, season, 1)],
},
);
}
FocusNode overviewFocusNode(WidgetTester tester) {
final overviewFocus = find.byWidgetPredicate(
(widget) => widget is Focus && widget.focusNode?.debugLabel == 'overview',
description: 'overview focus widget',
);
expect(overviewFocus, findsOneWidget);
return tester.widget<Focus>(overviewFocus).focusNode!;
}
testWidgets('overflowing overview DOWN reaches the first real section', (tester) async {
const summary =
'A deliberately extensive overview repeats enough concrete detail to exceed the collapsed line limit. '
'It describes the setting, the characters, the central conflict, and the consequences in full. '
'A second passage adds more background, more context, and more narrative detail for the viewer. '
'A third passage ensures the overview remains overflowing even across a wide phone test viewport. '
'Finally, another complete passage keeps the text beyond four generous lines without relying on font timing.';
final show = buildShow(summary: summary);
await pumpPhoneDetail(tester, singleSeasonClient(show), show);
final overviewText = tester.widget<Text>(
find.descendant(of: find.byType(CollapsibleText), matching: find.byType(Text)).first,
);
expect(overviewText.textSpan, isNotNull);
expect(overviewText.textSpan!.toPlainText(), isNot(summary));
final overviewNode = overviewFocusNode(tester);
overviewNode.requestFocus();
await tester.pump();
expect(overviewNode.hasFocus, isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'first_episode');
});
testWidgets('short overview accepts focus and preserves DOWN then episode UP navigation', (tester) async {
const summary = 'A short overview.';
final show = buildShow(summary: summary);
await pumpPhoneDetail(tester, singleSeasonClient(show), show);
expect(find.text(summary), findsOneWidget);
final overviewNode = overviewFocusNode(tester);
expect(overviewNode.context, isNotNull);
overviewNode.requestFocus();
await tester.pump();
expect(overviewNode.hasFocus, isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'first_episode');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'overview');
});
testWidgets('phone detail focuses requested season tab', (tester) async {
final show = buildShow();
final season1 = buildSeason(show, 1);
+23 -2
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/focusable_action_bar.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
@@ -11,11 +12,12 @@ import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/music/music_playback_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:plezy/widgets/music/track_row.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/prefs.dart';
import '../../test_helpers/media_items.dart';
import '../../test_helpers/prefs.dart';
MediaItem _track(String id, String title) => testMediaItem(
id: id,
@@ -73,7 +75,11 @@ void main() {
Widget wrap(MusicPlaybackService service) {
final manager = MultiServerManager();
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(multiServerProvider.dispose);
addTearDown(service.dispose);
addTearDown(() {
multiServerProvider.dispose();
manager.dispose();
});
return TranslationProvider(
child: MultiProvider(
@@ -109,6 +115,21 @@ void main() {
expect(find.text('Gamma'), findsOneWidget);
});
testWidgets('renders all queue header action icons at 20px', (tester) async {
final service = _FakeQueueService([_track('t1', 'Alpha'), _track('t2', 'Beta'), _track('t3', 'Gamma')]);
await tester.pumpWidget(wrap(service));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
final headerIcons = tester
.widgetList<AppIcon>(find.descendant(of: find.byType(FocusableActionBar), matching: find.byType(AppIcon)))
.toList();
expect(headerIcons, hasLength(3));
expect(headerIcons.map((icon) => icon.size), everyElement(20));
});
testWidgets('tapping a played or upcoming row jumps to its queue index', (tester) async {
final service = _FakeQueueService([_track('t1', 'Alpha'), _track('t2', 'Beta'), _track('t3', 'Gamma')]);
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/focusable_button.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/models/external_player_models.dart';
import 'package:plezy/screens/settings/external_player_screen.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/widgets/focusable_list_tile.dart';
import '../../test_helpers/prefs.dart';
void main() {
late SettingsService settings;
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
settings = await SettingsService.getInstance();
LocaleSettings.setLocaleSync(AppLocale.en);
});
tearDown(() {
SettingsService.resetForTesting();
resetSharedPreferencesForTest();
});
testWidgets('only custom players expose a focusable delete action', (tester) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1000, 1400);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
final customPlayer = ExternalPlayer.custom(
id: 'custom-test-player',
name: 'Custom Test Player',
value: 'custom-player',
type: CustomPlayerType.command,
);
await settings.write(SettingsService.useExternalPlayer, true);
await settings.write(SettingsService.customExternalPlayers, [customPlayer]);
await settings.write(SettingsService.selectedExternalPlayer, customPlayer);
await tester.pumpWidget(MaterialApp(theme: monoTheme(dark: true), home: const ExternalPlayerScreen()));
await tester.pumpAndSettle();
for (final player in KnownPlayers.getForCurrentPlatform()) {
final title = player.id == KnownPlayers.systemDefault.id ? 'System Default' : player.name;
final row = find.widgetWithText(FocusableListTile, title);
expect(row, findsOneWidget);
expect(find.descendant(of: row, matching: find.byType(FocusableButton)), findsNothing);
expect(find.descendant(of: row, matching: find.byType(IconButton)), findsNothing);
}
final customRow = find.widgetWithText(FocusableListTile, customPlayer.name);
expect(customRow, findsOneWidget);
final focusableDelete = find.descendant(of: customRow, matching: find.byType(FocusableButton));
final deleteControl = find.descendant(of: customRow, matching: find.byType(IconButton));
expect(focusableDelete, findsOneWidget);
expect(deleteControl, findsOneWidget);
expect(tester.widget<FocusableButton>(focusableDelete).onPressed, isNotNull);
await tester.tap(deleteControl);
await tester.pumpAndSettle();
expect(settings.read(SettingsService.customExternalPlayers), isEmpty);
expect(settings.read(SettingsService.selectedExternalPlayer), KnownPlayers.systemDefault);
expect(find.text(customPlayer.name), findsNothing);
});
}
@@ -485,6 +485,30 @@ class FakeMediaControlsManager extends MediaControlsManager {
}
}
class _GatedVolumeWriter {
final writes = <double>[];
final gates = <Completer<void>>[];
final _writeWaiters = <int, Completer<void>>{};
var inFlight = 0;
var maxInFlight = 0;
Future<void> write(double volume) {
writes.add(volume);
inFlight++;
if (inFlight > maxInFlight) maxInFlight = inFlight;
final gate = Completer<void>();
gates.add(gate);
_writeWaiters.remove(writes.length)?.complete();
return gate.future.whenComplete(() => inFlight--);
}
Future<void> waitForWrites(int count) {
if (writes.length >= count) return Future<void>.value();
return (_writeWaiters[count] ??= Completer<void>()).future;
}
}
class _Harness {
_Harness._(this.service, this.resolver, this.client, this.controls, this.players);
@@ -500,7 +524,7 @@ class _Harness {
FakePlayer get player => players.last;
factory _Harness.create() {
factory _Harness.create({Future<void> Function(double)? volumePersistenceWriter}) {
final client = FakeMediaServerClient();
final resolver = FakeMusicSourceResolver(client: client);
final controls = FakeMediaControlsManager();
@@ -519,6 +543,7 @@ class _Harness {
// Collapse the boundary-pulse confirmation window so completed-driven
// paths resolve within pumpEventQueue.
completedConfirmDelay: Duration.zero,
volumePersistenceWriter: volumePersistenceWriter,
);
harness = _Harness._(service, resolver, client, controls, players);
return harness;
@@ -541,9 +566,11 @@ void main() {
final t3 = _track('t3');
late _Harness h;
late Future<void> Function(double) persistenceWriter;
setUp(() {
h = _Harness.create();
persistenceWriter = (_) async {};
h = _Harness.create(volumePersistenceWriter: (volume) => persistenceWriter(volume));
});
tearDown(() {
@@ -569,6 +596,83 @@ void main() {
expect(serviceNotifications, 0);
});
test('volume persistence keeps one write in flight and coalesces a burst to the latest value', () async {
final writer = _GatedVolumeWriter();
persistenceWriter = writer.write;
var settled = 0;
final callers = [
h.service.setVolume(10).whenComplete(() => settled++),
h.service.setVolume(20).whenComplete(() => settled++),
h.service.setVolume(30).whenComplete(() => settled++),
];
await writer.waitForWrites(1);
expect(writer.writes, [10]);
expect(writer.inFlight, 1);
expect(writer.maxInFlight, 1);
expect(settled, 0);
writer.gates[0].complete();
await writer.waitForWrites(2);
expect(writer.writes, [10, 30]);
expect(writer.inFlight, 1);
expect(writer.maxInFlight, 1);
expect(settled, 0);
writer.gates[1].complete();
await Future.wait(callers);
expect(writer.inFlight, 0);
expect(settled, 3);
});
test('volume persistence propagates a drain error and accepts a later write', () async {
final writer = _GatedVolumeWriter();
persistenceWriter = writer.write;
final persistenceError = StateError('persistence failed');
var firstSettled = false;
var secondSettled = false;
final first = h.service
.setVolume(40)
.then<void>(
(_) => fail('first caller unexpectedly succeeded'),
onError: (Object error, StackTrace stackTrace) {
firstSettled = true;
expect(error, same(persistenceError));
},
);
final second = h.service
.setVolume(50)
.then<void>(
(_) => fail('second caller unexpectedly succeeded'),
onError: (Object error, StackTrace stackTrace) {
secondSettled = true;
expect(error, same(persistenceError));
},
);
await writer.waitForWrites(1);
writer.gates[0].completeError(persistenceError);
await writer.waitForWrites(2);
expect(writer.writes, [40, 50]);
expect(firstSettled, isFalse);
expect(secondSettled, isFalse);
writer.gates[1].complete();
await Future.wait([first, second]);
expect(firstSettled, isTrue);
expect(secondSettled, isTrue);
expect(writer.inFlight, 0);
final recovered = h.service.setVolume(60);
await writer.waitForWrites(3);
expect(writer.writes, [40, 50, 60]);
writer.gates[2].complete();
await recovered;
expect(writer.inFlight, 0);
});
test('playFromList opens the first track and arms the second', () async {
await h.playTracks([t1, t2, t3]);
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/watch_together/models/watch_session.dart';
import 'package:plezy/watch_together/providers/watch_together_provider.dart';
import 'package:plezy/watch_together/widgets/watch_together_overlay.dart';
import 'package:plezy/widgets/overlay_sheet.dart';
import 'package:provider/provider.dart';
void main() {
setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en));
for (final isHost in [false, true]) {
final role = isHost ? 'host' : 'guest';
testWidgets('$role confirmation survives the session sheet closing', (tester) async {
final harness = _OverlayHarness(isHost: isHost);
addTearDown(harness.dispose);
await tester.pumpWidget(harness.build());
await _openLeaveConfirmation(tester, harness);
harness.sheetController.close();
await tester.pumpAndSettle();
expect(find.text(t.watchTogether.title), findsNothing);
expect(
find.text(isHost ? t.watchTogether.endSessionQuestion : t.watchTogether.leaveSessionQuestion),
findsOneWidget,
);
await tester.tap(find.text(isHost ? t.watchTogether.endSession : t.watchTogether.leave));
await tester.pumpAndSettle();
expect(harness.provider.leaveCalls, 1);
expect(harness.onLeaveSessionCalls, 1);
});
testWidgets('$role cancellation remains a no-op after the session sheet closes', (tester) async {
final harness = _OverlayHarness(isHost: isHost);
addTearDown(harness.dispose);
await tester.pumpWidget(harness.build());
await _openLeaveConfirmation(tester, harness);
harness.sheetController.close();
await tester.pumpAndSettle();
expect(
find.text(isHost ? t.watchTogether.endSessionQuestion : t.watchTogether.leaveSessionQuestion),
findsOneWidget,
);
await tester.tap(find.text(t.common.cancel));
await tester.pumpAndSettle();
expect(harness.provider.leaveCalls, 0);
expect(harness.onLeaveSessionCalls, 0);
});
}
}
Future<void> _openLeaveConfirmation(WidgetTester tester, _OverlayHarness harness) async {
await tester.tap(find.byKey(_OverlayHarness.indicatorKey));
await tester.pumpAndSettle();
expect(harness.sheetController.isOpen, isTrue);
await tester.tap(find.text(harness.provider.isHost ? t.watchTogether.endSession : t.watchTogether.leaveSession));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
}
class _OverlayHarness {
_OverlayHarness({required bool isHost}) : provider = _FakeWatchTogetherProvider(isHostValue: isHost);
static const indicatorKey = Key('watch-together-session-indicator');
final _FakeWatchTogetherProvider provider;
late OverlaySheetController sheetController;
int onLeaveSessionCalls = 0;
Widget build() {
return ChangeNotifierProvider<WatchTogetherProvider>.value(
value: provider,
child: MaterialApp(
home: OverlaySheetHost(
child: Builder(
builder: (context) {
sheetController = OverlaySheetController.of(context);
return Scaffold(
body: Center(
child: WatchTogetherSessionIndicator(key: indicatorKey, onLeaveSession: () => onLeaveSessionCalls++),
),
);
},
),
),
),
);
}
void dispose() => provider.dispose();
}
class _FakeWatchTogetherProvider extends WatchTogetherProvider {
_FakeWatchTogetherProvider({required this.isHostValue});
final bool isHostValue;
var leaveCalls = 0;
var _isDisposing = false;
@override
bool get isHost => isHostValue;
@override
String? get sessionId => 'ROOM42';
@override
ControlMode get controlMode => ControlMode.hostOnly;
@override
List<Participant> get participants => [
Participant(peerId: 'local', displayName: 'Local viewer', isHost: isHostValue),
];
@override
int get participantCount => participants.length;
@override
Future<void> leaveSession() async {
if (!_isDisposing) leaveCalls++;
}
@override
void dispose() {
_isDisposing = true;
super.dispose();
}
}
+41
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/widgets/collapsible_text.dart';
@@ -35,6 +36,46 @@ void main() {
expect(focusNode.skipTraversal, isTrue);
});
testWidgets('short focused text keeps navigation without an expand action', (tester) async {
final semantics = tester.ensureSemantics();
final focusNode = FocusNode(debugLabel: 'short_collapsible_text');
addTearDown(focusNode.dispose);
var downCount = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 320,
child: CollapsibleText(
text: 'Short overview',
maxLines: 2,
focusNode: focusNode,
skipTraversal: false,
onNavigateDown: () => downCount++,
),
),
),
),
);
await tester.pump();
expect(focusNode.context, isNotNull);
focusNode.requestFocus();
await tester.pump();
expect(focusNode.hasFocus, isTrue);
final node = tester.getSemantics(find.text('Short overview'));
expect(node.label, 'Short overview');
expect(node.getSemanticsData().hasAction(SemanticsAction.tap), isFalse);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(downCount, 1);
semantics.dispose();
});
testWidgets('reports whether text overflows', (tester) async {
bool? overflows;
+110
View File
@@ -0,0 +1,110 @@
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/providers/download_provider.dart';
import 'package:plezy/services/download_manager_service.dart';
import 'package:plezy/services/download_storage_service.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/collapsible_text.dart';
import 'package:plezy/widgets/episode_card.dart';
import 'package:provider/provider.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
TvDetectionService.debugSetAppleTVOverride(false);
LocaleSettings.setLocaleSync(AppLocale.en);
await SettingsService.getInstance();
});
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
});
testWidgets('overflowing summary stays in card semantics without an Expand label', (tester) async {
final semantics = tester.ensureSemantics();
const summary =
'The expedition follows a careful team through an unfamiliar landscape while each discovery changes their plans.';
final episode = testMediaItem(
id: 'semantic_episode',
backend: MediaBackend.jellyfin,
kind: MediaKind.episode,
title: 'A Difficult Crossing',
index: 3,
summary: summary,
durationMs: 42 * 60 * 1000,
);
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
JellyfinApiCache.initialize(db);
final downloadManager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (serverId, {clientScopeId}) => null,
);
downloadManager.recoveryFuture = Future<void>.value();
final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await downloadProvider.ensureInitialized();
addTearDown(() async {
downloadProvider.dispose();
downloadManager.dispose();
await db.close();
});
await tester.pumpWidget(
TranslationProvider(
child: ChangeNotifierProvider<DownloadProvider>.value(
value: downloadProvider,
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 360,
child: EpisodeCard(episode: episode, isOffline: true, onTap: () {}),
),
),
),
),
),
);
await tester.pump();
final summaryText = tester.widget<Text>(
find.descendant(of: find.byType(CollapsibleText), matching: find.byType(Text)).first,
);
expect(summaryText.textSpan, isNotNull);
expect(summaryText.textSpan!.toPlainText(), isNot(summary));
final semanticNodes = <SemanticsNode>[];
void collectSemantics(SemanticsNode node) {
semanticNodes.add(node);
node.visitChildren((child) {
collectSemantics(child);
return true;
});
}
collectSemantics(tester.binding.renderViews.single.owner!.semanticsOwner!.rootSemanticsNode!);
final cardSemantics = semanticNodes.singleWhere((node) => node.label.contains('A Difficult Crossing'));
expect(cardSemantics.label, contains('The expedition follows a careful team'));
expect(cardSemantics.label, isNot(contains('Expand')));
expect(cardSemantics.getSemanticsData().hasAction(SemanticsAction.tap), isTrue);
semantics.dispose();
});
}
+171
View File
@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/dpad_navigator.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/models/hotkey_model.dart';
import 'package:plezy/screens/settings/hotkey_recorder_widget.dart';
import 'package:plezy/widgets/dialog_action_button.dart';
import 'package:plezy/widgets/hotkey_recorder.dart';
void main() {
tearDown(SelectKeyUpSuppressor.clearSuppression);
testWidgets('initially unbound shortcut captures from a tap and saves', (tester) async {
final saved = <HotKey>[];
await _pumpRecorder(tester, saved: saved);
expect(_recorder(tester).enabled, isFalse);
expect(find.text(t.hotkeys.pressToRecord), findsNWidgets(2));
expect(FocusManager.instance.primaryFocus?.debugLabel, 'HotKeyRecorder.record');
expect(_saveAction(tester).onPressed, isNull);
await tester.tap(find.byType(HotKeyRecorder));
await tester.pump();
expect(_recorder(tester).enabled, isTrue);
expect(find.text(t.hotkeys.recordingShortcut), findsNWidgets(2));
expect(FocusManager.instance.primaryFocus?.debugLabel, 'HotKeyRecorder.record');
await tester.sendKeyEvent(LogicalKeyboardKey.keyK, physicalKey: PhysicalKeyboardKey.keyK);
await _pumpFocusChange(tester);
expect(_recorder(tester).enabled, isFalse);
expect(find.text(physicalKeyLabel(PhysicalKeyboardKey.keyK)), findsOneWidget);
expect(find.text(t.hotkeys.pressToRecord), findsOneWidget);
expect(FocusManager.instance.primaryFocus?.debugLabel, 'HotKeyRecorder.save');
expect(saved, isEmpty);
await tester.tap(find.widgetWithText(FilledButton, t.common.save));
expect(saved, hasLength(1));
expect(saved.single.key, PhysicalKeyboardKey.keyK);
expect(saved.single.modifiers, isNull);
});
testWidgets('cleared shortcut can capture and save a replacement', (tester) async {
final saved = <HotKey>[];
await _pumpRecorder(
tester,
saved: saved,
currentHotKey: const HotKey(key: PhysicalKeyboardKey.keyJ, modifiers: [HotKeyModifier.shift]),
);
expect(find.text(physicalKeyLabel(PhysicalKeyboardKey.keyJ)), findsOneWidget);
expect(_saveAction(tester).onPressed, isNotNull);
await tester.tap(find.byTooltip(t.hotkeys.clearShortcut));
await tester.pump();
expect(_recorder(tester).enabled, isFalse);
expect(find.text(physicalKeyLabel(PhysicalKeyboardKey.keyJ)), findsNothing);
expect(find.text(t.hotkeys.pressToRecord), findsNWidgets(2));
expect(FocusManager.instance.primaryFocus?.debugLabel, 'HotKeyRecorder.record');
expect(_saveAction(tester).onPressed, isNull);
await tester.tap(find.byType(HotKeyRecorder));
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.keyL, physicalKey: PhysicalKeyboardKey.keyL);
await _pumpFocusChange(tester);
expect(_recorder(tester).enabled, isFalse);
expect(find.text(physicalKeyLabel(PhysicalKeyboardKey.keyL)), findsOneWidget);
expect(FocusManager.instance.primaryFocus?.debugLabel, 'HotKeyRecorder.save');
await tester.tap(find.widgetWithText(FilledButton, t.common.save));
expect(saved, hasLength(1));
expect(saved.single.key, PhysicalKeyboardKey.keyL);
expect(saved.single.modifiers, isNull);
});
testWidgets('modifier-first Control+P completes with the held modifier', (tester) async {
final saved = <HotKey>[];
await _pumpRecorder(tester, saved: saved);
await tester.tap(find.byType(HotKeyRecorder));
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft, physicalKey: PhysicalKeyboardKey.controlLeft);
await tester.pump();
expect(_recorder(tester).enabled, isTrue);
expect(saved, isEmpty);
expect(find.text(physicalKeyLabel(PhysicalKeyboardKey.controlLeft)), findsOneWidget);
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyP, physicalKey: PhysicalKeyboardKey.keyP);
await _pumpFocusChange(tester);
expect(_recorder(tester).enabled, isFalse);
expect(FocusManager.instance.primaryFocus?.debugLabel, 'HotKeyRecorder.save');
expect(find.text(physicalKeyLabel(PhysicalKeyboardKey.controlLeft)), findsOneWidget);
expect(find.text(physicalKeyLabel(PhysicalKeyboardKey.keyP)), findsOneWidget);
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyP, physicalKey: PhysicalKeyboardKey.keyP);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft, physicalKey: PhysicalKeyboardKey.controlLeft);
await tester.tap(find.widgetWithText(FilledButton, t.common.save));
expect(saved, hasLength(1));
expect(saved.single.key, PhysicalKeyboardKey.keyP);
expect(saved.single.modifiers, [HotKeyModifier.control]);
});
for (final entry in <(String, LogicalKeyboardKey, PhysicalKeyboardKey)>[
('Enter', LogicalKeyboardKey.enter, PhysicalKeyboardKey.enter),
('select', LogicalKeyboardKey.select, PhysicalKeyboardKey.select),
]) {
testWidgets('${entry.$1} completion does not rearm capture or activate Save on key-up', (tester) async {
final saved = <HotKey>[];
await _pumpRecorder(tester, saved: saved);
await tester.tap(find.byType(HotKeyRecorder));
await tester.pump();
await tester.sendKeyDownEvent(entry.$2, physicalKey: entry.$3);
await _pumpFocusChange(tester);
expect(_recorder(tester).enabled, isFalse);
expect(find.text(t.hotkeys.recordingShortcut), findsNothing);
expect(find.text(physicalKeyLabel(entry.$3)), findsOneWidget);
expect(FocusManager.instance.primaryFocus?.debugLabel, 'HotKeyRecorder.save');
expect(saved, isEmpty);
await tester.sendKeyUpEvent(entry.$2, physicalKey: entry.$3);
await tester.pump();
expect(_recorder(tester).enabled, isFalse);
expect(FocusManager.instance.primaryFocus?.debugLabel, 'HotKeyRecorder.save');
expect(saved, isEmpty);
await tester.tap(find.widgetWithText(FilledButton, t.common.save));
expect(saved, hasLength(1));
expect(saved.single.key, entry.$3);
expect(saved.single.modifiers, isNull);
});
}
}
Future<void> _pumpRecorder(WidgetTester tester, {required List<HotKey> saved, HotKey? currentHotKey}) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: HotKeyRecorderWidget(
actionName: 'Play/Pause',
currentHotKey: currentHotKey,
onHotKeyRecorded: saved.add,
onCancel: () {},
),
),
),
);
await tester.pump();
}
Future<void> _pumpFocusChange(WidgetTester tester) async {
await tester.pump();
await tester.pump();
}
HotKeyRecorder _recorder(WidgetTester tester) => tester.widget(find.byType(HotKeyRecorder));
DialogActionButton _saveAction(WidgetTester tester) =>
tester.widget(find.widgetWithText(DialogActionButton, t.common.save));
@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/dpad_navigator.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_library.dart';
import 'package:plezy/providers/hidden_libraries_provider.dart';
import 'package:plezy/providers/libraries_provider.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/library_management_sheet.dart';
import 'package:plezy/widgets/overlay_sheet.dart';
import 'package:provider/provider.dart';
import '../test_helpers/prefs.dart';
Future<({int Function() selects, int Function() backs})> _pumpLibraryManagementLauncher(WidgetTester tester) async {
final librariesProvider = LibrariesProvider();
await librariesProvider.updateLibraryOrder([
const MediaLibrary(id: 'movies', backend: MediaBackend.plex, title: 'Movies', kind: MediaKind.movie),
]);
addTearDown(librariesProvider.dispose);
final hiddenLibrariesProvider = HiddenLibrariesProvider();
await hiddenLibrariesProvider.ensureInitialized();
addTearDown(hiddenLibrariesProvider.dispose);
var underlyingSelects = 0;
var underlyingBacks = 0;
await tester.pumpWidget(
TranslationProvider(
child: InputModeTracker(
child: MultiProvider(
providers: [
ChangeNotifierProvider<LibrariesProvider>.value(value: librariesProvider),
ChangeNotifierProvider<HiddenLibrariesProvider>.value(value: hiddenLibrariesProvider),
],
child: MaterialApp(
theme: monoTheme(dark: true),
home: Focus(
onKeyEvent: (_, event) {
if (event is KeyDownEvent && event.logicalKey.isSelectKey) underlyingSelects++;
if (event is KeyDownEvent && event.logicalKey.isBackKey) underlyingBacks++;
return KeyEventResult.ignored;
},
child: OverlaySheetHost(
child: Scaffold(
body: Center(
child: Builder(
builder: (context) => ElevatedButton(
autofocus: true,
onPressed: () => showLibraryManagementSheet(context),
child: const Text('Open library management'),
),
),
),
),
),
),
),
),
),
),
);
await tester.pumpAndSettle();
return (selects: () => underlyingSelects, backs: () => underlyingBacks);
}
Future<void> _openScanConfirmation(WidgetTester tester) async {
// Switch from the desktop pointer default to keyboard mode, then activate the
// focused launcher using the same key path as a keyboard/remote user.
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(find.text(t.libraries.manageLibraries), findsOneWidget);
// The sheet owns one focus node for its virtual row/column navigation. Move
// from the row to its options column and open the real AppMenuSheet.
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(find.text(t.libraries.scanLibraryFiles), findsOneWidget);
// The hosted menu focuses its first entry in keyboard mode. Selecting it
// must close the whole hosted sheet before presenting the confirmation.
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text(t.libraries.manageLibraries), findsNothing);
expect(find.text(t.libraries.scanLibraryFiles), findsNothing);
expect(OverlaySheetController.openSheetCount.value, 0);
final dialogElement = tester.element(find.byType(AlertDialog));
final primaryFocusContext = FocusManager.instance.primaryFocus?.context;
var dialogOwnsPrimaryFocus = false;
primaryFocusContext?.visitAncestorElements((element) {
if (identical(element, dialogElement)) {
dialogOwnsPrimaryFocus = true;
return false;
}
return true;
});
expect(dialogOwnsPrimaryFocus, isTrue);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
resetSharedPreferencesForTest();
LocaleSettings.setLocaleSync(AppLocale.en);
TvDetectionService.debugSetAppleTVOverride(false);
TvDetectionService.setForceTVSync(false);
PlatformDetector.debugSetIsDesktopOSOverride(false);
});
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
TvDetectionService.setForceTVSync(false);
PlatformDetector.debugSetIsDesktopOSOverride(null);
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.automatic;
});
for (final interaction in [
(name: 'Enter', key: LogicalKeyboardKey.enter),
(name: 'Back', key: LogicalKeyboardKey.escape),
]) {
testWidgets('${interaction.name} is handled by confirmation after the hosted action sheet closes', (tester) async {
final underlyingActions = await _pumpLibraryManagementLauncher(tester);
await _openScanConfirmation(tester);
// Ignore launcher/menu navigation. From this point onward, neither key
// may reach the underlying page while the modal confirmation has focus.
final selectsBeforeDialogAction = underlyingActions.selects();
final backsBeforeDialogAction = underlyingActions.backs();
await tester.sendKeyEvent(interaction.key);
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
expect(find.text('Open library management'), findsOneWidget);
expect(underlyingActions.selects(), selectsBeforeDialogAction);
expect(underlyingActions.backs(), backsBeforeDialogAction);
expect(OverlaySheetController.openSheetCount.value, 0);
});
}
}
+197 -4
View File
@@ -1,21 +1,36 @@
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/connection/connection_registry.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/focus/focusable_action_bar.dart';
import 'package:plezy/focus/focusable_wrapper.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/models/download_models.dart';
import 'package:plezy/profiles/active_profile_provider.dart';
import 'package:plezy/profiles/plex_home_service.dart';
import 'package:plezy/profiles/profile_connection_registry.dart';
import 'package:plezy/profiles/profile_registry.dart';
import 'package:plezy/providers/download_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/music/music_playback_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/music_navigation.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:plezy/widgets/music/mini_player.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/prefs.dart';
import '../../test_helpers/media_items.dart';
import '../../test_helpers/prefs.dart';
final _track = testMediaItem(
id: 'track_1',
@@ -31,9 +46,33 @@ final _track = testMediaItem(
serverName: 'Server',
);
class _RecordingNavigatorObserver extends NavigatorObserver {
final List<Route<dynamic>> pushedRoutes = [];
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
pushedRoutes.add(route);
}
}
class _FakeDownloadProvider extends ChangeNotifier implements DownloadProvider {
@override
DownloadProgress? getProgress(String globalKey) => null;
@override
bool hasSyncRule(String globalKey) => false;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
/// Fixed-state fake: reports a playing session with a single-track queue.
class _FakeMusicService extends StubMusicPlaybackService {
MediaItem? track;
int previousCalls = 0;
int toggleCalls = 0;
int nextCalls = 0;
int stopCalls = 0;
_FakeMusicService({this.track});
@@ -54,6 +93,26 @@ class _FakeMusicService extends StubMusicPlaybackService {
@override
int get currentIndex => track == null ? -1 : 0;
@override
Future<void> previous() async {
previousCalls++;
}
@override
Future<void> togglePlayPause() async {
toggleCalls++;
}
@override
Future<void> next() async {
nextCalls++;
}
@override
Future<void> stop() async {
stopCalls++;
}
}
void main() {
@@ -70,10 +129,22 @@ void main() {
TvDetectionService.debugSetAppleTVOverride(null);
});
Widget wrap({required MusicPlaybackService service, required MusicUiRouteObserver observer}) {
Widget wrap({
required MusicPlaybackService service,
required MusicUiRouteObserver observer,
TargetPlatform platform = TargetPlatform.android,
NavigatorObserver? navigatorObserver,
ActiveProfileProvider? activeProfileProvider,
DownloadProvider? downloadProvider,
}) {
final manager = MultiServerManager();
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(multiServerProvider.dispose);
addTearDown(service.dispose);
addTearDown(observer.suppress.dispose);
addTearDown(() {
multiServerProvider.dispose();
manager.dispose();
});
return TranslationProvider(
child: MultiProvider(
@@ -82,9 +153,13 @@ void main() {
ChangeNotifierProvider<MusicPlaybackService>.value(value: service),
ChangeNotifierProvider<MiniPlayerInsetController>(create: (_) => MiniPlayerInsetController()),
Provider<MusicUiRouteObserver>.value(value: observer),
if (activeProfileProvider != null)
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
if (downloadProvider != null) ChangeNotifierProvider<DownloadProvider>.value(value: downloadProvider),
],
child: MaterialApp(
theme: monoTheme(dark: true),
theme: monoTheme(dark: true).copyWith(platform: platform),
navigatorObservers: [?navigatorObserver],
home: const Stack(
children: [
SizedBox.expand(),
@@ -108,6 +183,124 @@ void main() {
expect(find.byType(IconButton), findsNWidgets(2)); // play/pause + next (mobile layout)
});
testWidgets('tapping the card edge opens the named Now Playing route', (tester) async {
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
final navigatorObserver = _RecordingNavigatorObserver();
await tester.pumpWidget(wrap(service: service, observer: observer, navigatorObserver: navigatorObserver));
await tester.pumpAndSettle();
final cardRect = tester.getRect(find.byKey(const ValueKey('mini_player_dismiss')));
await tester.tapAt(Offset(cardRect.left + 2, cardRect.center.dy));
final route = navigatorObserver.pushedRoutes.last;
expect(route.settings.name, kNowPlayingRouteName);
expect(service.previousCalls, 0);
expect(service.toggleCalls, 0);
expect(service.nextCalls, 0);
expect(service.stopCalls, 0);
route.navigator!.removeRoute(route);
});
testWidgets('desktop transport buttons own their actions without opening Now Playing', (tester) async {
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
final navigatorObserver = _RecordingNavigatorObserver();
await tester.pumpWidget(
wrap(service: service, observer: observer, platform: TargetPlatform.macOS, navigatorObserver: navigatorObserver),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip(t.music.previousTrack));
await tester.tap(find.byTooltip(t.common.pause));
await tester.tap(find.byTooltip(t.music.nextTrack));
await tester.tap(find.byTooltip(t.music.stopPlayback));
expect(service.previousCalls, 1);
expect(service.toggleCalls, 1);
expect(service.nextCalls, 1);
expect(service.stopCalls, 1);
expect(navigatorObserver.pushedRoutes.where((route) => route.settings.name == kNowPlayingRouteName), isEmpty);
});
testWidgets('desktop close icon alone is 20px while transport icons remain 24px', (tester) async {
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
await tester.pumpWidget(wrap(service: service, observer: observer, platform: TargetPlatform.macOS));
await tester.pumpAndSettle();
final icons = tester
.widgetList<AppIcon>(find.descendant(of: find.byType(FocusableActionBar), matching: find.byType(AppIcon)))
.toList();
expect(icons, hasLength(4));
expect(icons.singleWhere((icon) => icon.icon == Symbols.close_rounded).size, 20);
expect(icons.where((icon) => icon.icon != Symbols.close_rounded).map((icon) => icon.size), everyElement(24));
});
testWidgets('keyboard long-press anchors the context menu to the focused card instead of a stale pointer', (
tester,
) async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
final connections = ConnectionRegistry(db);
final profileConnections = ProfileConnectionRegistry(db);
final plexHome = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
plexHomeUserFetcher: (_) async => const [],
);
final activeProfileProvider = ActiveProfileProvider(
registry: ProfileRegistry(db),
plexHome: plexHome,
connections: connections,
);
final downloadProvider = _FakeDownloadProvider();
addTearDown(() async {
activeProfileProvider.dispose();
downloadProvider.dispose();
await plexHome.dispose();
await db.close();
});
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
await tester.pumpWidget(
wrap(
service: service,
observer: observer,
activeProfileProvider: activeProfileProvider,
downloadProvider: downloadProvider,
),
);
await tester.pumpAndSettle();
final card = find.byKey(const ValueKey('mini_player_dismiss'));
final cardRect = tester.getRect(card);
final gesture = await tester.startGesture(Offset(cardRect.left + 4, cardRect.center.dy));
await tester.pump(const Duration(milliseconds: 150));
await gesture.cancel();
await tester.pump();
tester.widget<FocusableWrapper>(find.byType(FocusableWrapper)).focusNode!.requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
await tester.pump(const Duration(milliseconds: 550));
await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(find.text(t.common.play), findsOneWidget);
final menuSurface = find.byWidgetPredicate((widget) => widget is Material && widget.elevation == 3);
expect(menuSurface, findsOneWidget);
expect(tester.getCenter(menuSurface).dx, closeTo(cardRect.center.dx, 0.1));
await tester.tapAt(const Offset(4, 4));
await tester.pumpAndSettle();
});
testWidgets('stays hidden while the route observer suppresses it', (tester) async {
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
+141
View File
@@ -140,6 +140,147 @@ void main() {
expect(screenBacks, 0);
});
testWidgets('pushAdaptive opens a root page on an idle host and returns its result', (tester) async {
final result = ValueNotifier<String>('pending');
addTearDown(result.dispose);
await tester.pumpWidget(
MaterialApp(
home: OverlaySheetHost(
child: Scaffold(
body: Builder(
builder: (context) => Column(
children: [
ElevatedButton(
onPressed: () async {
final value = await OverlaySheetController.pushAdaptive<String>(
context,
builder: (sheetContext) => SizedBox(
height: 120,
child: Column(
children: [
const Text('Adaptive root page'),
ElevatedButton(
onPressed: () => OverlaySheetController.of(sheetContext).close('root result'),
child: const Text('Close adaptive root'),
),
],
),
),
);
result.value = value ?? 'null';
},
child: const Text('Push adaptive root'),
),
ValueListenableBuilder<String>(
valueListenable: result,
builder: (_, value, _) => Text('Root result: $value'),
),
],
),
),
),
),
),
);
await tester.tap(find.text('Push adaptive root'));
await tester.pumpAndSettle();
expect(find.text('Adaptive root page'), findsOneWidget);
expect(find.text('Root result: pending'), findsOneWidget);
await tester.tap(find.text('Close adaptive root'));
await tester.pumpAndSettle();
expect(find.text('Adaptive root page'), findsNothing);
expect(find.text('Root result: root result'), findsOneWidget);
});
testWidgets('pushAdaptive pushes a nested page on an open host and pop restores the root', (tester) async {
final nestedResult = ValueNotifier<String>('pending');
addTearDown(nestedResult.dispose);
await tester.pumpWidget(
MaterialApp(
home: OverlaySheetHost(
child: Scaffold(
body: Center(
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () {
OverlaySheetController.of(context).show<void>(
builder: (rootContext) => SizedBox(
height: 160,
child: Column(
children: [
const Text('Existing root page'),
ElevatedButton(
onPressed: () async {
final value = await OverlaySheetController.pushAdaptive<String>(
rootContext,
builder: (nestedContext) => SizedBox(
height: 120,
child: Column(
children: [
const Text('Adaptive nested page'),
ElevatedButton(
onPressed: () =>
OverlaySheetController.of(nestedContext).pop('nested result'),
child: const Text('Pop adaptive nested'),
),
],
),
),
);
nestedResult.value = value ?? 'null';
},
child: const Text('Push adaptive nested'),
),
ValueListenableBuilder<String>(
valueListenable: nestedResult,
builder: (_, value, _) => Text('Nested result: $value'),
),
ElevatedButton(
onPressed: () => OverlaySheetController.of(rootContext).close(),
child: const Text('Close existing root'),
),
],
),
),
);
},
child: const Text('Open existing root'),
),
),
),
),
),
),
);
await tester.tap(find.text('Open existing root'));
await tester.pumpAndSettle();
expect(find.text('Existing root page'), findsOneWidget);
await tester.tap(find.text('Push adaptive nested'));
await tester.pumpAndSettle();
expect(find.text('Adaptive nested page'), findsOneWidget);
expect(find.text('Existing root page'), findsNothing);
await tester.tap(find.text('Pop adaptive nested'));
await tester.pumpAndSettle();
expect(find.text('Adaptive nested page'), findsNothing);
expect(find.text('Existing root page'), findsOneWidget);
expect(find.text('Nested result: nested result'), findsOneWidget);
await tester.tap(find.text('Close existing root'));
await tester.pumpAndSettle();
expect(find.text('Existing root page'), findsNothing);
});
group('opt-in canPop / onSystemBack', () {
// Pushes an OverlaySheetHost route on top of a home route so we can observe
// whether a simulated system back pops the route. The host's child has an