fix: restore focus and interaction behavior
This commit is contained in:
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: () {},
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user