fix: improve d-pad focus navigation
This commit is contained in:
@@ -6,6 +6,7 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../services/plex_auth_service.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../theme/mono_tokens.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
@@ -222,9 +223,15 @@ class _PlexPinAuthFlowState extends State<PlexPinAuthFlow> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilledButton(onPressed: busy ? null : browser, child: Text(t.auth.signInWithPlex)),
|
||||
FocusableButton(
|
||||
onPressed: busy ? null : browser,
|
||||
child: FilledButton(onPressed: busy ? null : browser, child: Text(t.auth.signInWithPlex)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(onPressed: busy ? null : qr, child: Text(t.auth.showQRCode)),
|
||||
FocusableButton(
|
||||
onPressed: busy ? null : qr,
|
||||
child: OutlinedButton(onPressed: busy ? null : qr, child: Text(t.auth.showQRCode)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -251,10 +258,13 @@ class _PlexPinAuthFlowState extends State<PlexPinAuthFlow> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
OutlinedButton(
|
||||
FocusableButton(
|
||||
onPressed: _retry,
|
||||
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24)),
|
||||
child: Text(t.common.retry),
|
||||
child: OutlinedButton(
|
||||
onPressed: _retry,
|
||||
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24)),
|
||||
child: Text(t.common.retry),
|
||||
),
|
||||
),
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
@@ -280,10 +290,13 @@ class _PlexPinAuthFlowState extends State<PlexPinAuthFlow> {
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton(
|
||||
FocusableButton(
|
||||
onPressed: _retry,
|
||||
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24)),
|
||||
child: Text(t.common.retry),
|
||||
child: OutlinedButton(
|
||||
onPressed: _retry,
|
||||
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24)),
|
||||
child: Text(t.common.retry),
|
||||
),
|
||||
),
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@@ -17,6 +17,7 @@ import '../utils/app_logger.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../focus/focusable_text_field.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../media/media_backend.dart';
|
||||
import '../utils/navigation_transitions.dart';
|
||||
import '../widgets/backend_badge.dart';
|
||||
@@ -203,61 +204,65 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
// Use two-column layout on desktop, single column on mobile
|
||||
final isDesktop = MediaQuery.sizeOf(context).width > 700;
|
||||
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: isDesktop ? 800 : 400),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: isDesktop
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset('assets/plezy.png', width: 120, height: 120),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
t.app.title,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
return Focus(
|
||||
canRequestFocus: false,
|
||||
onKeyEvent: (_, event) => handleBackKeyNavigation(context, event),
|
||||
child: Scaffold(
|
||||
body: Center(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: isDesktop ? 800 : 400),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: isDesktop
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset('assets/plezy.png', width: 120, height: 120),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
t.app.title,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 48),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [_buildAuthBody()],
|
||||
const SizedBox(width: 48),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [_buildAuthBody()],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Image.asset('assets/plezy.png', width: 120, height: 120),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
t.app.title,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
_buildAuthBody(),
|
||||
],
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Image.asset('assets/plezy.png', width: 120, height: 120),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
t.app.title,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
_buildAuthBody(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -322,17 +327,23 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
),
|
||||
],
|
||||
] else ...[
|
||||
ElevatedButton.icon(
|
||||
FocusableButton(
|
||||
onPressed: busy ? null : startBrowser,
|
||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
icon: const BackendBadge(backend: MediaBackend.plex, size: 18),
|
||||
label: Text(t.auth.signInWithPlex),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: busy ? null : startBrowser,
|
||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
icon: const BackendBadge(backend: MediaBackend.plex, size: 18),
|
||||
label: Text(t.auth.signInWithPlex),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
FocusableButton(
|
||||
onPressed: busy ? null : startQr,
|
||||
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
child: Text(t.auth.showQRCode),
|
||||
child: OutlinedButton(
|
||||
onPressed: busy ? null : startQr,
|
||||
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
child: Text(t.auth.showQRCode),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
@@ -363,21 +374,27 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
),
|
||||
)
|
||||
else
|
||||
OutlinedButton.icon(
|
||||
FocusableButton(
|
||||
onPressed: _connectToJellyfin,
|
||||
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
icon: const BackendBadge(backend: MediaBackend.jellyfin, size: 18),
|
||||
label: Text(t.auth.connectToJellyfin),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _connectToJellyfin,
|
||||
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
icon: const BackendBadge(backend: MediaBackend.jellyfin, size: 18),
|
||||
label: Text(t.auth.connectToJellyfin),
|
||||
),
|
||||
),
|
||||
if (kDebugMode) ...[
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
FocusableButton(
|
||||
onPressed: _handleDebugTap,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)),
|
||||
child: OutlinedButton(
|
||||
onPressed: _handleDebugTap,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: const Text('Debug: Enter Plex Token', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
child: const Text('Debug: Enter Plex Token', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
],
|
||||
if (_errorMessage != null) ...[
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../media/media_filter.dart';
|
||||
import '../../services/plex_client.dart';
|
||||
import '../../utils/scroll_utils.dart';
|
||||
@@ -228,15 +229,23 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
|
||||
title: t.libraries.filters,
|
||||
leading: const AppIcon(Symbols.filter_alt_rounded, fill: 1),
|
||||
action: _tempSelectedFilters.isNotEmpty
|
||||
? TextButton.icon(
|
||||
? FocusableButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_tempSelectedFilters.clear();
|
||||
});
|
||||
_applyFilters();
|
||||
},
|
||||
icon: const AppIcon(Symbols.clear_all_rounded, fill: 1),
|
||||
label: Text(t.libraries.clearAll),
|
||||
child: TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_tempSelectedFilters.clear();
|
||||
});
|
||||
_applyFilters();
|
||||
},
|
||||
icon: const AppIcon(Symbols.clear_all_rounded, fill: 1),
|
||||
label: Text(t.libraries.clearAll),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../focus/dpad_navigator.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../focus/input_mode_tracker.dart';
|
||||
import '../../media/media_sort.dart';
|
||||
import '../../utils/scroll_utils.dart';
|
||||
@@ -107,7 +108,12 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
|
||||
children: [
|
||||
BottomSheetHeader(
|
||||
title: t.libraries.sortBy,
|
||||
action: widget.onClear != null ? TextButton(onPressed: _handleClear, child: Text(t.common.clear)) : null,
|
||||
action: widget.onClear != null
|
||||
? FocusableButton(
|
||||
onPressed: _handleClear,
|
||||
child: TextButton(onPressed: _handleClear, child: Text(t.common.clear)),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
Flexible(
|
||||
child: RadioGroup<MediaSort>(
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../focus/focusable_button.dart';
|
||||
|
||||
/// Base widget for displaying state messages (empty, error, etc.)
|
||||
/// Provides a consistent UI pattern for showing icons, messages, and actions
|
||||
class StateMessageWidget extends StatelessWidget {
|
||||
@@ -84,10 +86,13 @@ class StateMessageWidget extends StatelessWidget {
|
||||
],
|
||||
if (onAction != null && actionLabel != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
FocusableButton(
|
||||
onPressed: onAction,
|
||||
icon: AppIcon(actionIcon ?? Symbols.refresh_rounded, fill: 1),
|
||||
label: Text(actionLabel!),
|
||||
child: FilledButton.icon(
|
||||
onPressed: onAction,
|
||||
icon: AppIcon(actionIcon ?? Symbols.refresh_rounded, fill: 1),
|
||||
label: Text(actionLabel!),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../focus/focusable_action_bar.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../media/live_tv_support.dart';
|
||||
import '../../media/media_server_client.dart';
|
||||
@@ -606,10 +607,14 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!, style: theme.textTheme.bodyLarge),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
FocusableButton(
|
||||
autofocus: true,
|
||||
onPressed: _loadChannels,
|
||||
icon: const AppIcon(Symbols.refresh_rounded),
|
||||
label: Text(t.common.retry),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _loadChannels,
|
||||
icon: const AppIcon(Symbols.refresh_rounded),
|
||||
label: Text(t.common.retry),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -509,6 +509,8 @@ class _IntSettingRowState extends State<_IntSettingRow> with ControllerDisposerM
|
||||
autofocus: widget.autofocus,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'-?\d*'))],
|
||||
onNavigateUp: () => FocusScope.of(context).previousFocus(),
|
||||
onNavigateDown: () => FocusScope.of(context).nextFocus(),
|
||||
onChanged: (text) {
|
||||
final parsed = int.tryParse(text);
|
||||
widget.onChanged(parsed);
|
||||
@@ -571,6 +573,8 @@ class _TextSettingRowState extends State<_TextSettingRow> with ControllerDispose
|
||||
FocusableTextField(
|
||||
controller: _controller,
|
||||
autofocus: widget.autofocus,
|
||||
onNavigateUp: () => FocusScope.of(context).previousFocus(),
|
||||
onNavigateDown: () => FocusScope.of(context).nextFocus(),
|
||||
onChanged: (text) => widget.onChanged(text),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -3,13 +3,14 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../mixins/controller_disposer_mixin.dart';
|
||||
import '../../profiles/profile.dart';
|
||||
import '../../profiles/profile_registry.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import '../../widgets/app_icon.dart';
|
||||
import '../../widgets/desktop_app_bar.dart';
|
||||
import '../../widgets/focused_scroll_scaffold.dart';
|
||||
import '../settings/add_connection_screen.dart';
|
||||
import 'pin_entry_dialog.dart';
|
||||
import 'pin_status_row.dart';
|
||||
@@ -30,9 +31,22 @@ class AddLocalProfileScreen extends StatefulWidget {
|
||||
|
||||
class _AddLocalProfileScreenState extends State<AddLocalProfileScreen> with ControllerDisposerMixin {
|
||||
late final TextEditingController _nameController = createTextEditingController();
|
||||
final _nameFocus = FocusNode(debugLabel: 'AddLocalProfile:Name');
|
||||
final _setPinFocus = FocusNode(debugLabel: 'AddLocalProfile:SetPin');
|
||||
final _continueFocus = FocusNode(debugLabel: 'AddLocalProfile:Continue');
|
||||
final _cancelFocus = FocusNode(debugLabel: 'AddLocalProfile:Cancel');
|
||||
String? _pinHash;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameFocus.dispose();
|
||||
_setPinFocus.dispose();
|
||||
_continueFocus.dispose();
|
||||
_cancelFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _setPin() async {
|
||||
final pin = await captureAndConfirmPin(
|
||||
context,
|
||||
@@ -73,49 +87,72 @@ class _AddLocalProfileScreenState extends State<AddLocalProfileScreen> with Cont
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
ExcludeFocus(child: CustomAppBar(title: Text(t.profiles.newProfile), pinned: true)),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
Text(t.profiles.profileNameLabel, style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
ProfileNameField(
|
||||
controller: _nameController,
|
||||
hintText: t.profiles.profileNameHint,
|
||||
onChanged: () => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(t.profiles.pinProtectionOptional, style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
t.profiles.pinExplain,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_pinHash == null)
|
||||
OutlinedButton.icon(
|
||||
return FocusedScrollScaffold(
|
||||
title: Text(t.profiles.newProfile),
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
Text(t.profiles.profileNameLabel, style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
ProfileNameField(
|
||||
controller: _nameController,
|
||||
focusNode: _nameFocus,
|
||||
hintText: t.profiles.profileNameHint,
|
||||
onChanged: () => setState(() {}),
|
||||
onNavigateDown: () => (_pinHash == null ? _setPinFocus : _continueFocus).requestFocus(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(t.profiles.pinProtectionOptional, style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
t.profiles.pinExplain,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_pinHash == null)
|
||||
FocusableButton(
|
||||
focusNode: _setPinFocus,
|
||||
useBackgroundFocus: true,
|
||||
onPressed: _setPin,
|
||||
onNavigateUp: () => _nameFocus.requestFocus(),
|
||||
onNavigateDown: () => _continueFocus.requestFocus(),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _setPin,
|
||||
icon: const AppIcon(Symbols.lock_outline_rounded, fill: 1),
|
||||
label: Text(t.profiles.setPin),
|
||||
)
|
||||
else
|
||||
PinStatusRow(onChange: _setPin, onRemove: _clearPin),
|
||||
const SizedBox(height: 32),
|
||||
FilledButton(
|
||||
),
|
||||
)
|
||||
else
|
||||
PinStatusRow(onChange: _setPin, onRemove: _clearPin),
|
||||
const SizedBox(height: 32),
|
||||
FocusableButton(
|
||||
focusNode: _continueFocus,
|
||||
useBackgroundFocus: true,
|
||||
onPressed: _saving || _nameController.text.trim().isEmpty ? null : _saveAndContinue,
|
||||
onNavigateUp: () => (_pinHash == null ? _setPinFocus : _nameFocus).requestFocus(),
|
||||
onNavigateDown: () => _cancelFocus.requestFocus(),
|
||||
child: FilledButton(
|
||||
onPressed: _saving || _nameController.text.trim().isEmpty ? null : _saveAndContinue,
|
||||
child: Text(t.profiles.continueButton),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(onPressed: _saving ? null : () => Navigator.of(context).pop(), child: Text(t.common.cancel)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FocusableButton(
|
||||
focusNode: _cancelFocus,
|
||||
useBackgroundFocus: true,
|
||||
onPressed: _saving ? null : () => Navigator.of(context).pop(),
|
||||
onNavigateUp: () => _continueFocus.requestFocus(),
|
||||
child: TextButton(
|
||||
onPressed: _saving ? null : () => Navigator.of(context).pop(),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import '../../utils/snackbar_helper.dart';
|
||||
import '../../widgets/app_icon.dart';
|
||||
import '../../widgets/backend_badge.dart';
|
||||
import '../../widgets/loading_indicator_box.dart';
|
||||
import '../../widgets/desktop_app_bar.dart';
|
||||
import '../../widgets/focused_scroll_scaffold.dart';
|
||||
import '../libraries/state_messages.dart';
|
||||
import 'pin_entry_dialog.dart';
|
||||
|
||||
@@ -156,57 +156,50 @@ class _BorrowConnectionScreenState extends State<BorrowConnectionScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: FutureBuilder<List<_BorrowCandidate>>(
|
||||
future: _candidatesFuture,
|
||||
builder: (context, snapshot) {
|
||||
final candidates = snapshot.data ?? const <_BorrowCandidate>[];
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
ExcludeFocus(
|
||||
child: CustomAppBar(
|
||||
title: Text(t.profiles.borrowAddTo(displayName: widget.targetProfile.displayName)),
|
||||
pinned: true,
|
||||
),
|
||||
return FutureBuilder<List<_BorrowCandidate>>(
|
||||
future: _candidatesFuture,
|
||||
builder: (context, snapshot) {
|
||||
final candidates = snapshot.data ?? const <_BorrowCandidate>[];
|
||||
return FocusedScrollScaffold(
|
||||
title: Text(t.profiles.borrowAddTo(displayName: widget.targetProfile.displayName)),
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Text(t.profiles.borrowExplain, style: Theme.of(context).textTheme.bodySmall),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Text(t.profiles.borrowExplain, style: Theme.of(context).textTheme.bodySmall),
|
||||
),
|
||||
if (snapshot.connectionState != ConnectionState.done)
|
||||
LoadingIndicatorBox.sliver
|
||||
else if (candidates.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: EmptyStateWidget(
|
||||
message: t.profiles.borrowEmpty,
|
||||
subtitle: t.profiles.borrowEmptySubtitle,
|
||||
icon: Symbols.share_rounded,
|
||||
iconSize: 48,
|
||||
),
|
||||
),
|
||||
if (snapshot.connectionState != ConnectionState.done)
|
||||
LoadingIndicatorBox.sliver
|
||||
else if (candidates.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: EmptyStateWidget(
|
||||
message: t.profiles.borrowEmpty,
|
||||
subtitle: t.profiles.borrowEmptySubtitle,
|
||||
icon: Symbols.share_rounded,
|
||||
iconSize: 48,
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final cand = candidates[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: FocusableWrapper(
|
||||
autofocus: index == 0,
|
||||
disableScale: true,
|
||||
onSelect: _busy ? null : () => _borrow(cand),
|
||||
child: Card(
|
||||
child: _BorrowTile(candidate: cand, onTap: () => _borrow(cand)),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final cand = candidates[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: FocusableWrapper(
|
||||
autofocus: index == 0,
|
||||
disableScale: true,
|
||||
onSelect: _busy ? null : () => _borrow(cand),
|
||||
child: Card(
|
||||
child: _BorrowTile(candidate: cand, onTap: () => _borrow(cand)),
|
||||
),
|
||||
);
|
||||
}, childCount: candidates.length),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}, childCount: candidates.length),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../widgets/app_icon.dart';
|
||||
|
||||
/// "PIN set" pill + Change/Remove text buttons. Shown on profile creation
|
||||
@@ -32,8 +33,14 @@ class PinStatusRow extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
TextButton(onPressed: onChange, child: const Text('Change')),
|
||||
TextButton(onPressed: onRemove, child: const Text('Remove')),
|
||||
FocusableButton(
|
||||
onPressed: onChange,
|
||||
child: TextButton(onPressed: onChange, child: const Text('Change')),
|
||||
),
|
||||
FocusableButton(
|
||||
onPressed: onRemove,
|
||||
child: TextButton(onPressed: onRemove, child: const Text('Remove')),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,9 +19,11 @@ import '../../profiles/profile_registry.dart';
|
||||
import '../../profiles/profiles_view.dart';
|
||||
import '../../providers/download_provider.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../widgets/app_icon.dart';
|
||||
import '../../widgets/backend_badge.dart';
|
||||
import '../../widgets/desktop_app_bar.dart';
|
||||
import '../../widgets/focusable_popup_menu_button.dart';
|
||||
import '../../widgets/focused_scroll_scaffold.dart';
|
||||
import '../../utils/dialogs.dart';
|
||||
import '../settings/add_connection_screen.dart';
|
||||
import 'pin_entry_dialog.dart';
|
||||
@@ -46,6 +48,11 @@ class ProfileDetailScreen extends StatefulWidget {
|
||||
|
||||
class _ProfileDetailScreenState extends State<ProfileDetailScreen> with ControllerDisposerMixin {
|
||||
late final TextEditingController _nameController = createTextEditingController(text: widget.profile.displayName);
|
||||
final _nameFocusNode = FocusNode(debugLabel: 'ProfileDetail:Name');
|
||||
final _saveNameFocusNode = FocusNode(debugLabel: 'ProfileDetail:SaveName');
|
||||
final _setPinFocusNode = FocusNode(debugLabel: 'ProfileDetail:SetPin');
|
||||
final _addConnectionFocusNode = FocusNode(debugLabel: 'ProfileDetail:AddConnection');
|
||||
final _deleteProfileFocusNode = FocusNode(debugLabel: 'ProfileDetail:DeleteProfile');
|
||||
late Profile _profile;
|
||||
|
||||
@override
|
||||
@@ -54,6 +61,16 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
|
||||
_profile = widget.profile;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameFocusNode.dispose();
|
||||
_saveNameFocusNode.dispose();
|
||||
_setPinFocusNode.dispose();
|
||||
_addConnectionFocusNode.dispose();
|
||||
_deleteProfileFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _saveName() async {
|
||||
final name = _nameController.text.trim();
|
||||
if (name.isEmpty || name == _profile.displayName) return;
|
||||
@@ -130,76 +147,96 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
|
||||
final theme = Theme.of(context);
|
||||
final isLocal = _profile.isLocal;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
ExcludeFocus(child: CustomAppBar(title: Text(_profile.displayName), pinned: true)),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
Center(child: ProfileAvatar(profile: _profile, size: 96)),
|
||||
const SizedBox(height: 24),
|
||||
Text(t.profiles.profileNameLabel, style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
if (isLocal)
|
||||
ProfileNameField(
|
||||
controller: _nameController,
|
||||
onChanged: () => setState(() {}),
|
||||
trailing: FilledButton(
|
||||
return FocusedScrollScaffold(
|
||||
title: Text(_profile.displayName),
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
Center(child: ProfileAvatar(profile: _profile, size: 96)),
|
||||
const SizedBox(height: 24),
|
||||
Text(t.profiles.profileNameLabel, style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
if (isLocal)
|
||||
ProfileNameField(
|
||||
controller: _nameController,
|
||||
focusNode: _nameFocusNode,
|
||||
onChanged: () => setState(() {}),
|
||||
onNavigateRight: _saveNameFocusNode.requestFocus,
|
||||
trailing: FocusableButton(
|
||||
focusNode: _saveNameFocusNode,
|
||||
onNavigateLeft: _nameFocusNode.requestFocus,
|
||||
onPressed:
|
||||
_nameController.text.trim().isEmpty || _nameController.text.trim() == _profile.displayName
|
||||
? null
|
||||
: _saveName,
|
||||
child: FilledButton(
|
||||
onPressed:
|
||||
_nameController.text.trim().isEmpty || _nameController.text.trim() == _profile.displayName
|
||||
? null
|
||||
: _saveName,
|
||||
child: Text(t.common.save),
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
_profile.displayName,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(t.profiles.pinProtectionLabel, style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
if (!isLocal)
|
||||
Text(
|
||||
_profile.plexProtected ? t.profiles.pinManagedByPlex : t.profiles.noPinSetEditOnPlex,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
)
|
||||
else if (_profile.pinHash == null)
|
||||
OutlinedButton.icon(
|
||||
)
|
||||
else
|
||||
Text(
|
||||
_profile.displayName,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(t.profiles.pinProtectionLabel, style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
if (!isLocal)
|
||||
Text(
|
||||
_profile.plexProtected ? t.profiles.pinManagedByPlex : t.profiles.noPinSetEditOnPlex,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
)
|
||||
else if (_profile.pinHash == null)
|
||||
FocusableButton(
|
||||
focusNode: _setPinFocusNode,
|
||||
onPressed: _setPin,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _setPin,
|
||||
icon: const AppIcon(Symbols.lock_outline_rounded, fill: 1),
|
||||
label: Text(t.profiles.setPin),
|
||||
)
|
||||
else
|
||||
PinStatusRow(onChange: _setPin, onRemove: _clearPin),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text(t.profiles.connectionsLabel, style: theme.textTheme.labelLarge)),
|
||||
TextButton.icon(
|
||||
),
|
||||
)
|
||||
else
|
||||
PinStatusRow(onChange: _setPin, onRemove: _clearPin),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text(t.profiles.connectionsLabel, style: theme.textTheme.labelLarge)),
|
||||
FocusableButton(
|
||||
focusNode: _addConnectionFocusNode,
|
||||
onPressed: _addConnection,
|
||||
child: TextButton.icon(
|
||||
onPressed: _addConnection,
|
||||
icon: const AppIcon(Symbols.add_rounded, fill: 1),
|
||||
label: Text(t.profiles.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_ConnectionsList(profile: _profile, onRemove: _removeConnection),
|
||||
const SizedBox(height: 24),
|
||||
if (isLocal)
|
||||
OutlinedButton.icon(
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_ConnectionsList(profile: _profile, onRemove: _removeConnection),
|
||||
const SizedBox(height: 24),
|
||||
if (isLocal)
|
||||
FocusableButton(
|
||||
focusNode: _deleteProfileFocusNode,
|
||||
onPressed: _deleteProfile,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _deleteProfile,
|
||||
icon: AppIcon(Symbols.delete_outline_rounded, fill: 1, color: theme.colorScheme.error),
|
||||
label: Text(t.profiles.deleteProfileButton, style: TextStyle(color: theme.colorScheme.error)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -269,21 +306,19 @@ class _ConnectionsList extends StatelessWidget {
|
||||
leading: BackendBadge(backend: conn.backend, size: 24),
|
||||
title: Text(conn.displayLabel),
|
||||
subtitle: _ConnectionSubtitle.build(conn: conn, pc: pc, homeCache: homeCache, theme: theme),
|
||||
trailing: PopupMenuButton<String>(
|
||||
trailing: FocusablePopupMenuButton<String>(
|
||||
icon: const AppIcon(Symbols.more_vert_rounded, fill: 1),
|
||||
tooltip: t.profiles.manage,
|
||||
onSelected: (value) {
|
||||
if (value == 'default') {
|
||||
unawaited(pcRegistry.setDefault(profile.id, pc.connectionId));
|
||||
} else if (value == 'remove') {
|
||||
unawaited(onRemove(pc, conn));
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
if (!pc.isDefault)
|
||||
PopupMenuItem(
|
||||
value: 'default',
|
||||
onTap: () => WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => pcRegistry.setDefault(profile.id, pc.connectionId),
|
||||
),
|
||||
child: Text(t.profiles.makeDefault),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'remove',
|
||||
onTap: () => WidgetsBinding.instance.addPostFrameCallback((_) => onRemove(pc, conn)),
|
||||
child: Text(t.profiles.removeConnection),
|
||||
),
|
||||
if (!pc.isDefault) PopupMenuItem(value: 'default', child: Text(t.profiles.makeDefault)),
|
||||
PopupMenuItem(value: 'remove', child: Text(t.profiles.removeConnection)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,20 +6,44 @@ import '../../focus/focusable_text_field.dart';
|
||||
/// the profile-detail rename row. Optional [trailing] slot for an inline Save
|
||||
/// button — pass `null` when the screen saves elsewhere (e.g. on Continue).
|
||||
class ProfileNameField extends StatelessWidget {
|
||||
const ProfileNameField({super.key, required this.controller, this.hintText, this.trailing, this.onChanged});
|
||||
const ProfileNameField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.focusNode,
|
||||
this.hintText,
|
||||
this.trailing,
|
||||
this.onChanged,
|
||||
this.autofocus = false,
|
||||
this.onNavigateUp,
|
||||
this.onNavigateDown,
|
||||
this.onNavigateLeft,
|
||||
this.onNavigateRight,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final FocusNode? focusNode;
|
||||
final String? hintText;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onChanged;
|
||||
final bool autofocus;
|
||||
final VoidCallback? onNavigateUp;
|
||||
final VoidCallback? onNavigateDown;
|
||||
final VoidCallback? onNavigateLeft;
|
||||
final VoidCallback? onNavigateRight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final field = FocusableTextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
autofocus: autofocus,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: InputDecoration(hintText: hintText, border: const OutlineInputBorder()),
|
||||
onChanged: (_) => onChanged?.call(),
|
||||
onNavigateUp: onNavigateUp ?? () => FocusScope.of(context).previousFocus(),
|
||||
onNavigateDown: onNavigateDown ?? () => FocusScope.of(context).nextFocus(),
|
||||
onNavigateLeft: onNavigateLeft,
|
||||
onNavigateRight: onNavigateRight,
|
||||
);
|
||||
if (trailing == null) return field;
|
||||
return Row(
|
||||
|
||||
@@ -26,6 +26,7 @@ import '../../utils/dialogs.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import '../../widgets/app_icon.dart';
|
||||
import '../../widgets/backend_badge.dart';
|
||||
import '../../widgets/focusable_popup_menu_button.dart';
|
||||
import '../../widgets/focused_scroll_scaffold.dart';
|
||||
import '../libraries/state_messages.dart';
|
||||
import '../auth_screen.dart';
|
||||
@@ -50,7 +51,9 @@ class ProfileSwitchScreen extends StatefulWidget {
|
||||
|
||||
class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedSetStateMixin {
|
||||
bool _allowPop = false;
|
||||
final FocusNode _firstSelectableFocusNode = FocusNode();
|
||||
final Map<String, FocusNode> _profileFocusNodes = {};
|
||||
final Map<String, FocusNode> _profileMenuFocusNodes = {};
|
||||
final Map<String, GlobalKey<PopupMenuButtonState<_TileAction>>> _profileMenuKeys = {};
|
||||
bool _focusRequested = false;
|
||||
bool _switching = false;
|
||||
Stream<ProfilesView>? _viewStream;
|
||||
@@ -77,7 +80,12 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_firstSelectableFocusNode.dispose();
|
||||
for (final node in _profileFocusNodes.values) {
|
||||
node.dispose();
|
||||
}
|
||||
for (final node in _profileMenuFocusNodes.values) {
|
||||
node.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -95,6 +103,7 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
|
||||
initialData: ProfilesView.empty,
|
||||
builder: (context, snapshot) {
|
||||
final view = snapshot.data ?? ProfilesView.empty;
|
||||
_pruneProfileFocusResources(view.profiles.map((p) => p.id).toSet());
|
||||
// `context.select` only rebuilds when `activeId` actually
|
||||
// changes. `context.watch` would rebuild on every provider
|
||||
// notification — combined with the stream, that doubles the
|
||||
@@ -122,6 +131,9 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: FocusableWrapper(
|
||||
disableScale: true,
|
||||
borderRadius: 100,
|
||||
useBackgroundFocus: true,
|
||||
descendantsAreFocusable: false,
|
||||
onSelect: _switching ? null : _addLocalProfile,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _switching ? null : _addLocalProfile,
|
||||
@@ -167,6 +179,40 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
|
||||
);
|
||||
}
|
||||
|
||||
FocusNode _profileFocusNode(Profile profile) {
|
||||
return _profileFocusNodes.putIfAbsent(profile.id, () => FocusNode(debugLabel: 'ProfileTile:${profile.id}'));
|
||||
}
|
||||
|
||||
FocusNode _profileMenuFocusNode(Profile profile) {
|
||||
return _profileMenuFocusNodes.putIfAbsent(profile.id, () => FocusNode(debugLabel: 'ProfileActions:${profile.id}'));
|
||||
}
|
||||
|
||||
GlobalKey<PopupMenuButtonState<_TileAction>> _profileMenuKey(Profile profile) {
|
||||
return _profileMenuKeys.putIfAbsent(profile.id, () => GlobalKey<PopupMenuButtonState<_TileAction>>());
|
||||
}
|
||||
|
||||
void _pruneProfileFocusResources(Set<String> activeIds) {
|
||||
for (final id in _profileFocusNodes.keys.toList()) {
|
||||
if (!activeIds.contains(id)) {
|
||||
_profileFocusNodes.remove(id)?.dispose();
|
||||
}
|
||||
}
|
||||
for (final id in _profileMenuFocusNodes.keys.toList()) {
|
||||
if (!activeIds.contains(id)) {
|
||||
_profileMenuFocusNodes.remove(id)?.dispose();
|
||||
}
|
||||
}
|
||||
for (final id in _profileMenuKeys.keys.toList()) {
|
||||
if (!activeIds.contains(id)) {
|
||||
_profileMenuKeys.remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _openProfileMenu(Profile profile) {
|
||||
_profileMenuKeys[profile.id]?.currentState?.showButtonMenu();
|
||||
}
|
||||
|
||||
List<Widget> _buildSections(ProfilesView view, String? activeId) {
|
||||
return [_profileList(view.profiles, view, activeId, autofocusFirst: true)];
|
||||
}
|
||||
@@ -177,11 +223,20 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
|
||||
final profile = profiles[index];
|
||||
final isActive = profile.id == activeId;
|
||||
final isFirstSelectable = autofocusFirst && index == 0;
|
||||
final profileFocusNode = _profileFocusNode(profile);
|
||||
final menuFocusNode = _profileMenuFocusNode(profile);
|
||||
final menuKey = _profileMenuKey(profile);
|
||||
final onManage = !widget.requireSelection ? () => _manageProfile(profile) : null;
|
||||
final onDelete = profile.isLocal && !widget.requireSelection ? () => _deleteProfile(profile) : null;
|
||||
final onSignOut = profile.isPlexHome && profile.parentConnectionId != null && !widget.requireSelection
|
||||
? () => _signOutPlexAccount(profile)
|
||||
: null;
|
||||
final hasMenu = onManage != null || onDelete != null || onSignOut != null;
|
||||
|
||||
if (isFirstSelectable && !_focusRequested) {
|
||||
_focusRequested = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _firstSelectableFocusNode.requestFocus();
|
||||
if (mounted) profileFocusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -189,8 +244,11 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: FocusableWrapper(
|
||||
autofocus: isFirstSelectable,
|
||||
focusNode: isFirstSelectable ? _firstSelectableFocusNode : null,
|
||||
focusNode: profileFocusNode,
|
||||
disableScale: true,
|
||||
enableLongPress: hasMenu,
|
||||
onLongPress: hasMenu ? () => _openProfileMenu(profile) : null,
|
||||
onNavigateRight: hasMenu ? () => menuFocusNode.requestFocus() : null,
|
||||
onSelect: _switching || (isActive && !widget.requireSelection) ? null : () => _switchTo(profile),
|
||||
child: Card(
|
||||
child: _ProfileTile(
|
||||
@@ -201,11 +259,12 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
|
||||
// Manage available for any profile — adding/removing
|
||||
// borrowed connections is supported on plex_home too. Delete
|
||||
// stays local-only (Plex Home users are owned by Plex).
|
||||
onManage: !widget.requireSelection ? () => _manageProfile(profile) : null,
|
||||
onDelete: profile.isLocal && !widget.requireSelection ? () => _deleteProfile(profile) : null,
|
||||
onSignOut: profile.isPlexHome && profile.parentConnectionId != null && !widget.requireSelection
|
||||
? () => _signOutPlexAccount(profile)
|
||||
: null,
|
||||
onManage: onManage,
|
||||
onDelete: onDelete,
|
||||
onSignOut: onSignOut,
|
||||
menuFocusNode: menuFocusNode,
|
||||
menuKey: menuKey,
|
||||
onMenuNavigateLeft: () => profileFocusNode.requestFocus(),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -365,6 +424,9 @@ class _ProfileTile extends StatelessWidget {
|
||||
final VoidCallback? onManage;
|
||||
final VoidCallback? onDelete;
|
||||
final VoidCallback? onSignOut;
|
||||
final FocusNode menuFocusNode;
|
||||
final GlobalKey<PopupMenuButtonState<_TileAction>> menuKey;
|
||||
final VoidCallback onMenuNavigateLeft;
|
||||
|
||||
const _ProfileTile({
|
||||
required this.profile,
|
||||
@@ -374,6 +436,9 @@ class _ProfileTile extends StatelessWidget {
|
||||
this.onManage,
|
||||
this.onDelete,
|
||||
this.onSignOut,
|
||||
required this.menuFocusNode,
|
||||
required this.menuKey,
|
||||
required this.onMenuNavigateLeft,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -425,28 +490,15 @@ class _ProfileTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
if (hasMenu)
|
||||
PopupMenuButton<_TileAction>(
|
||||
icon: const AppIcon(Symbols.more_vert_rounded, fill: 1),
|
||||
tooltip: 'Profile actions',
|
||||
itemBuilder: (_) => [
|
||||
if (onManage != null)
|
||||
PopupMenuItem(
|
||||
value: _TileAction.manage,
|
||||
onTap: () => WidgetsBinding.instance.addPostFrameCallback((_) => onManage?.call()),
|
||||
child: Text(t.profiles.manage),
|
||||
),
|
||||
if (onDelete != null)
|
||||
PopupMenuItem(
|
||||
value: _TileAction.delete,
|
||||
onTap: () => WidgetsBinding.instance.addPostFrameCallback((_) => onDelete?.call()),
|
||||
child: Text(t.profiles.delete),
|
||||
),
|
||||
if (onSignOut != null)
|
||||
PopupMenuItem(
|
||||
value: _TileAction.signOut,
|
||||
onTap: () => WidgetsBinding.instance.addPostFrameCallback((_) => onSignOut?.call()),
|
||||
child: Text(t.profiles.signOut),
|
||||
),
|
||||
_ProfileActionsButton(
|
||||
menuKey: menuKey,
|
||||
focusNode: menuFocusNode,
|
||||
onNavigateLeft: onMenuNavigateLeft,
|
||||
onSelected: _handleAction,
|
||||
actions: [
|
||||
if (onManage != null) _TileAction.manage,
|
||||
if (onDelete != null) _TileAction.delete,
|
||||
if (onSignOut != null) _TileAction.signOut,
|
||||
],
|
||||
)
|
||||
else if (!isActive)
|
||||
@@ -456,6 +508,62 @@ class _ProfileTile extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleAction(_TileAction action) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
switch (action) {
|
||||
case _TileAction.manage:
|
||||
onManage?.call();
|
||||
break;
|
||||
case _TileAction.delete:
|
||||
onDelete?.call();
|
||||
break;
|
||||
case _TileAction.signOut:
|
||||
onSignOut?.call();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileActionsButton extends StatelessWidget {
|
||||
final GlobalKey<PopupMenuButtonState<_TileAction>> menuKey;
|
||||
final FocusNode focusNode;
|
||||
final VoidCallback onNavigateLeft;
|
||||
final ValueChanged<_TileAction> onSelected;
|
||||
final List<_TileAction> actions;
|
||||
|
||||
const _ProfileActionsButton({
|
||||
required this.menuKey,
|
||||
required this.focusNode,
|
||||
required this.onNavigateLeft,
|
||||
required this.onSelected,
|
||||
required this.actions,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FocusablePopupMenuButton<_TileAction>(
|
||||
menuKey: menuKey,
|
||||
focusNode: focusNode,
|
||||
semanticLabel: t.profiles.manage,
|
||||
onNavigateLeft: onNavigateLeft,
|
||||
icon: const AppIcon(Symbols.more_vert_rounded, fill: 1),
|
||||
tooltip: t.profiles.manage,
|
||||
onSelected: onSelected,
|
||||
itemBuilder: (_) => [for (final action in actions) PopupMenuItem(value: action, child: Text(action.label))],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension _TileActionLabel on _TileAction {
|
||||
String get label {
|
||||
return switch (this) {
|
||||
_TileAction.manage => t.profiles.manage,
|
||||
_TileAction.delete => t.profiles.delete,
|
||||
_TileAction.signOut => t.profiles.signOut,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
enum _TileAction { manage, delete, signOut }
|
||||
|
||||
@@ -2,11 +2,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../focus/focusable_wrapper.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../media/media_backend.dart';
|
||||
import '../../profiles/profile.dart';
|
||||
import '../../widgets/backend_badge.dart';
|
||||
import '../../widgets/desktop_app_bar.dart';
|
||||
import '../../widgets/focused_scroll_scaffold.dart';
|
||||
import '../profile/borrow_connection_screen.dart';
|
||||
import 'add_jellyfin_screen.dart';
|
||||
import 'add_plex_account_screen.dart';
|
||||
@@ -46,64 +47,57 @@ class AddConnectionScreen extends StatelessWidget {
|
||||
builder: (_) => AddJellyfinScreen(targetProfile: targetProfile),
|
||||
),
|
||||
];
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
ExcludeFocus(
|
||||
child: CustomAppBar(
|
||||
title: Text(
|
||||
scoped
|
||||
? t.addServer.addConnectionTitleScoped(name: targetProfile!.displayName)
|
||||
: t.addServer.addConnectionTitle,
|
||||
),
|
||||
pinned: true,
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
Text(
|
||||
scoped ? t.addServer.addConnectionIntroScoped : t.addServer.addConnectionIntroGlobal,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
for (var i = 0; i < options.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 12),
|
||||
_BackendCard(
|
||||
leading: BackendBadge(backend: options[i].backend, size: 28),
|
||||
title: options[i].title,
|
||||
subtitle: options[i].subtitle,
|
||||
onTap: () async {
|
||||
final added = await Navigator.push<bool>(context, MaterialPageRoute(builder: options[i].builder));
|
||||
if (added == true && context.mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
if (scoped) ...[
|
||||
const SizedBox(height: 12),
|
||||
_BackendCard(
|
||||
leading: const AppIcon(Symbols.share_rounded, fill: 1, size: 28),
|
||||
title: t.addServer.borrowFromAnotherProfile,
|
||||
subtitle: t.addServer.borrowFromAnotherProfileSubtitle,
|
||||
onTap: () async {
|
||||
final added = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => BorrowConnectionScreen(targetProfile: targetProfile!)),
|
||||
);
|
||||
if (added == true && context.mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
return FocusedScrollScaffold(
|
||||
title: Text(
|
||||
scoped
|
||||
? t.addServer.addConnectionTitleScoped(name: targetProfile!.displayName)
|
||||
: t.addServer.addConnectionTitle,
|
||||
),
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
Text(
|
||||
scoped ? t.addServer.addConnectionIntroScoped : t.addServer.addConnectionIntroGlobal,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
for (var i = 0; i < options.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 12),
|
||||
_BackendCard(
|
||||
leading: BackendBadge(backend: options[i].backend, size: 28),
|
||||
title: options[i].title,
|
||||
subtitle: options[i].subtitle,
|
||||
onTap: () async {
|
||||
final added = await Navigator.push<bool>(context, MaterialPageRoute(builder: options[i].builder));
|
||||
if (added == true && context.mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
if (scoped) ...[
|
||||
const SizedBox(height: 12),
|
||||
_BackendCard(
|
||||
leading: const AppIcon(Symbols.share_rounded, fill: 1, size: 28),
|
||||
title: t.addServer.borrowFromAnotherProfile,
|
||||
subtitle: t.addServer.borrowFromAnotherProfileSubtitle,
|
||||
onTap: () async {
|
||||
final added = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => BorrowConnectionScreen(targetProfile: targetProfile!)),
|
||||
);
|
||||
if (added == true && context.mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -128,35 +122,41 @@ class _BackendCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Material(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
return FocusableWrapper(
|
||||
disableScale: true,
|
||||
borderRadius: 12,
|
||||
descendantsAreFocusable: false,
|
||||
onSelect: onTap,
|
||||
child: Material(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
leading,
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
leading,
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
],
|
||||
const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../connection/connection.dart';
|
||||
import '../../exceptions/media_server_exceptions.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../focus/focusable_text_field.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../mixins/controller_disposer_mixin.dart';
|
||||
@@ -21,7 +22,7 @@ import '../../services/jellyfin_auth_service.dart';
|
||||
import '../../services/storage_service.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../widgets/desktop_app_bar.dart';
|
||||
import '../../widgets/focused_scroll_scaffold.dart';
|
||||
import '../profile/profile_switch_screen.dart';
|
||||
import 'async_form_state_mixin.dart';
|
||||
import 'connection_persistence.dart';
|
||||
@@ -70,10 +71,16 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
late final _urlController = createTextEditingController();
|
||||
late final _usernameController = createTextEditingController();
|
||||
late final _passwordController = createTextEditingController();
|
||||
final _urlFocus = FocusNode(debugLabel: 'AddJellyfin:Url');
|
||||
final _findServerFocus = FocusNode(debugLabel: 'AddJellyfin:FindServer');
|
||||
final _usernameFocus = FocusNode(debugLabel: 'AddJellyfin:Username');
|
||||
// Owned so the username field can advance focus on Enter; mobile keyboards
|
||||
// act on `textInputAction: next` automatically but TV remotes / hardware
|
||||
// keyboards need the explicit `onFieldSubmitted` handler below.
|
||||
final _passwordFocus = FocusNode();
|
||||
final _passwordFocus = FocusNode(debugLabel: 'AddJellyfin:Password');
|
||||
final _signInFocus = FocusNode(debugLabel: 'AddJellyfin:SignIn');
|
||||
final _quickConnectFocus = FocusNode(debugLabel: 'AddJellyfin:QuickConnect');
|
||||
final _cancelQuickConnectFocus = FocusNode(debugLabel: 'AddJellyfin:CancelQuickConnect');
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
JellyfinServerInfo? _serverInfo;
|
||||
@@ -88,7 +95,13 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
// setState after the widget is gone.
|
||||
_qcCancelled = true;
|
||||
_qcAttemptId++;
|
||||
_urlFocus.dispose();
|
||||
_findServerFocus.dispose();
|
||||
_usernameFocus.dispose();
|
||||
_passwordFocus.dispose();
|
||||
_signInFocus.dispose();
|
||||
_quickConnectFocus.dispose();
|
||||
_cancelQuickConnectFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -308,21 +321,19 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
ExcludeFocus(child: CustomAppBar(title: Text(t.addServer.addJellyfinTitle), pinned: true)),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: _buildBodyChildren(theme)),
|
||||
),
|
||||
return FocusedScrollScaffold(
|
||||
title: Text(t.addServer.addJellyfinTitle),
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: _buildBodyChildren(theme)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -341,11 +352,13 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
const SizedBox(height: 16),
|
||||
FocusableTextFormField(
|
||||
controller: _urlController,
|
||||
focusNode: _urlFocus,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.url,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
enabled: !busy,
|
||||
onNavigateDown: _serverInfo == null ? () => _findServerFocus.requestFocus() : null,
|
||||
textInputAction: TextInputAction.go,
|
||||
onFieldSubmitted: busy ? null : (_) => _probe(),
|
||||
decoration: InputDecoration(
|
||||
@@ -356,10 +369,16 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
),
|
||||
if (_serverInfo == null) ...[
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
FocusableButton(
|
||||
focusNode: _findServerFocus,
|
||||
useBackgroundFocus: true,
|
||||
onPressed: busy ? null : _probe,
|
||||
icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.travel_explore_rounded, fill: 1),
|
||||
label: Text(t.addServer.findServer),
|
||||
onNavigateUp: () => _urlFocus.requestFocus(),
|
||||
child: FilledButton.icon(
|
||||
onPressed: busy ? null : _probe,
|
||||
icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.travel_explore_rounded, fill: 1),
|
||||
label: Text(t.addServer.findServer),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 16),
|
||||
@@ -367,9 +386,11 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
const SizedBox(height: 16),
|
||||
FocusableTextFormField(
|
||||
controller: _usernameController,
|
||||
focusNode: _usernameFocus,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
enabled: !busy,
|
||||
onNavigateDown: () => _passwordFocus.requestFocus(),
|
||||
textInputAction: TextInputAction.next,
|
||||
onFieldSubmitted: busy ? null : (_) => _passwordFocus.requestFocus(),
|
||||
decoration: InputDecoration(
|
||||
@@ -384,6 +405,8 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
focusNode: _passwordFocus,
|
||||
obscureText: true,
|
||||
enabled: !busy,
|
||||
onNavigateUp: () => _usernameFocus.requestFocus(),
|
||||
onNavigateDown: () => _signInFocus.requestFocus(),
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: busy ? null : (_) => _signIn(),
|
||||
decoration: InputDecoration(
|
||||
@@ -394,17 +417,30 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
// require a value.
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
FocusableButton(
|
||||
focusNode: _signInFocus,
|
||||
useBackgroundFocus: true,
|
||||
onPressed: busy ? null : _signIn,
|
||||
icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.login_rounded, fill: 1),
|
||||
label: Text(t.addServer.signIn),
|
||||
onNavigateUp: () => _passwordFocus.requestFocus(),
|
||||
onNavigateDown: _quickConnectEnabled ? () => _quickConnectFocus.requestFocus() : null,
|
||||
child: FilledButton.icon(
|
||||
onPressed: busy ? null : _signIn,
|
||||
icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.login_rounded, fill: 1),
|
||||
label: Text(t.addServer.signIn),
|
||||
),
|
||||
),
|
||||
if (_quickConnectEnabled) ...[
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
FocusableButton(
|
||||
focusNode: _quickConnectFocus,
|
||||
useBackgroundFocus: true,
|
||||
onPressed: busy ? null : _startQuickConnect,
|
||||
icon: const AppIcon(Symbols.tap_and_play_rounded, fill: 1),
|
||||
label: Text(t.auth.useQuickConnect),
|
||||
onNavigateUp: () => _signInFocus.requestFocus(),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: busy ? null : _startQuickConnect,
|
||||
icon: const AppIcon(Symbols.tap_and_play_rounded, fill: 1),
|
||||
label: Text(t.auth.useQuickConnect),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -492,10 +528,15 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
OutlinedButton.icon(
|
||||
FocusableButton(
|
||||
focusNode: _cancelQuickConnectFocus,
|
||||
useBackgroundFocus: true,
|
||||
onPressed: _cancelQuickConnect,
|
||||
icon: const AppIcon(Symbols.close_rounded, fill: 1),
|
||||
label: Text(t.auth.quickConnectCancel),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _cancelQuickConnect,
|
||||
icon: const AppIcon(Symbols.close_rounded, fill: 1),
|
||||
label: Text(t.auth.quickConnectCancel),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../../connection/connection.dart';
|
||||
import '../../connection/connection_registry.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../profiles/active_profile_binder.dart';
|
||||
import '../../profiles/active_profile_provider.dart';
|
||||
@@ -17,7 +18,7 @@ import '../../services/plex_auth_service.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../media/media_backend.dart';
|
||||
import '../../widgets/backend_badge.dart';
|
||||
import '../../widgets/desktop_app_bar.dart';
|
||||
import '../../widgets/focused_scroll_scaffold.dart';
|
||||
import '../auth/plex_pin_auth_flow.dart';
|
||||
import '../profile/borrow_connection_screen.dart';
|
||||
import 'async_form_state_mixin.dart';
|
||||
@@ -158,52 +159,58 @@ class _AddPlexAccountScreenState extends State<AddPlexAccountScreen> with AsyncF
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
ExcludeFocus(child: CustomAppBar(title: Text(t.addServer.addPlexTitle), pinned: true)),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(t.addServer.plexAuthIntro, style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 24),
|
||||
PlexPinAuthFlow(
|
||||
onTokenReceived: _onTokenReceived,
|
||||
initialButtonsBuilder: (context, browser, qr, busy) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
return FocusedScrollScaffold(
|
||||
title: Text(t.addServer.addPlexTitle),
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(t.addServer.plexAuthIntro, style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 24),
|
||||
PlexPinAuthFlow(
|
||||
onTokenReceived: _onTokenReceived,
|
||||
initialButtonsBuilder: (context, browser, qr, busy) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FocusableButton(
|
||||
useBackgroundFocus: true,
|
||||
onPressed: busy || this.busy ? null : browser,
|
||||
child: FilledButton.icon(
|
||||
onPressed: busy || this.busy ? null : browser,
|
||||
icon: const BackendBadge(backend: MediaBackend.plex, size: 18),
|
||||
label: Text(t.auth.signInWithPlex),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FocusableButton(
|
||||
useBackgroundFocus: true,
|
||||
onPressed: busy || this.busy ? null : qr,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: busy || this.busy ? null : qr,
|
||||
icon: const AppIcon(Symbols.qr_code_rounded, fill: 1),
|
||||
label: Text(t.auth.showQRCode),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (errorText != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
errorText!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (errorText != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
errorText!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../../i18n/strings.g.dart';
|
||||
import '../../models/hotkey_model.dart';
|
||||
import '../../services/keyboard_shortcuts_service.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../widgets/focused_scroll_scaffold.dart';
|
||||
import 'hotkey_recorder_widget.dart';
|
||||
|
||||
@@ -21,18 +22,21 @@ class KeyboardShortcutsScreen extends StatelessWidget {
|
||||
final actions = hotkeys.keys.toList();
|
||||
return FocusedScrollScaffold(
|
||||
title: Text(t.settings.keyboardShortcuts),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await keyboardService.resetToDefaults();
|
||||
if (context.mounted) showSuccessSnackBar(context, t.settings.shortcutsReset);
|
||||
},
|
||||
child: Text(t.common.reset),
|
||||
),
|
||||
],
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FocusableButton(
|
||||
onPressed: () => _resetShortcuts(context),
|
||||
child: TextButton(onPressed: () => _resetShortcuts(context), child: Text(t.common.reset)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final action = actions[index];
|
||||
@@ -66,6 +70,11 @@ class KeyboardShortcutsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _resetShortcuts(BuildContext context) async {
|
||||
await keyboardService.resetToDefaults();
|
||||
if (context.mounted) showSuccessSnackBar(context, t.settings.shortcutsReset);
|
||||
}
|
||||
|
||||
void _editHotkey(BuildContext screenContext, String action, HotKey currentHotkey) {
|
||||
showDialog(
|
||||
context: screenContext,
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../../utils/snackbar_helper.dart';
|
||||
import '../../mixins/settings_effect_mixin.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
import '../../widgets/focused_scroll_scaffold.dart';
|
||||
import '../../widgets/focusable_popup_menu_button.dart';
|
||||
import '../../widgets/settings_builder.dart';
|
||||
|
||||
class MpvConfigScreen extends StatefulWidget {
|
||||
@@ -209,7 +210,8 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> with SettingsEffectMi
|
||||
(preset) => ListTile(
|
||||
leading: const AppIcon(Symbols.folder_rounded, fill: 1),
|
||||
title: Text(preset.name),
|
||||
trailing: PopupMenuButton<String>(
|
||||
trailing: FocusablePopupMenuButton<String>(
|
||||
icon: const AppIcon(Symbols.more_vert_rounded, fill: 1),
|
||||
onSelected: (value) {
|
||||
if (value == 'load') {
|
||||
_loadPreset(preset);
|
||||
|
||||
@@ -30,7 +30,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
FilledButton(
|
||||
FocusableButton(
|
||||
autofocus: true,
|
||||
onPressed: () {
|
||||
final playerToDispose = player;
|
||||
@@ -42,10 +42,28 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
||||
});
|
||||
unawaited(_initializePlayer());
|
||||
},
|
||||
child: Text(t.common.retry),
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
final playerToDispose = player;
|
||||
player = null;
|
||||
if (playerToDispose != null) unawaited(playerToDispose.dispose());
|
||||
_setPlayerState(() {
|
||||
_playerInitializationError = null;
|
||||
_isPlayerInitialized = false;
|
||||
});
|
||||
unawaited(_initializePlayer());
|
||||
},
|
||||
child: Text(t.common.retry),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton(onPressed: () => unawaited(_handleBackButton()), child: Text(t.common.back)),
|
||||
FocusableButton(
|
||||
onPressed: () => unawaited(_handleBackButton()),
|
||||
child: OutlinedButton(
|
||||
onPressed: () => unawaited(_handleBackButton()),
|
||||
child: Text(t.common.back),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -70,6 +70,7 @@ import 'video_player/widgets/player_prompt_overlays.dart';
|
||||
import '../widgets/overlay_sheet.dart';
|
||||
import '../widgets/video_controls/video_controls.dart';
|
||||
import '../widgets/video_controls/widgets/player_toast_indicator.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
|
||||
+31
-3
@@ -214,6 +214,8 @@ Future<String?> showMultilineTextInputDialog(
|
||||
/// the save button.
|
||||
mixin _TextInputDialogStateMixin<T extends StatefulWidget> on State<T>, ControllerDisposerMixin<T> {
|
||||
late final TextEditingController _controller;
|
||||
final _fieldFocusNode = FocusNode();
|
||||
final _cancelFocusNode = FocusNode();
|
||||
final _saveFocusNode = FocusNode();
|
||||
|
||||
String? get initialValue;
|
||||
@@ -226,6 +228,8 @@ mixin _TextInputDialogStateMixin<T extends StatefulWidget> on State<T>, Controll
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fieldFocusNode.dispose();
|
||||
_cancelFocusNode.dispose();
|
||||
_saveFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -255,19 +259,29 @@ class _MultilineTextInputDialogState extends State<_MultilineTextInputDialog>
|
||||
width: 400,
|
||||
child: FocusableTextField(
|
||||
controller: _controller,
|
||||
focusNode: _fieldFocusNode,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(labelText: widget.labelText),
|
||||
keyboardType: TextInputType.multiline,
|
||||
maxLines: 8,
|
||||
minLines: 3,
|
||||
onNavigateDown: _saveFocusNode.requestFocus,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel),
|
||||
DialogActionButton(
|
||||
focusNode: _cancelFocusNode,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
onNavigateUp: _fieldFocusNode.requestFocus,
|
||||
onNavigateRight: _saveFocusNode.requestFocus,
|
||||
label: t.common.cancel,
|
||||
),
|
||||
DialogActionButton(
|
||||
onPressed: () => Navigator.pop(context, _controller.text),
|
||||
label: t.common.save,
|
||||
focusNode: _saveFocusNode,
|
||||
onNavigateUp: _fieldFocusNode.requestFocus,
|
||||
onNavigateLeft: _cancelFocusNode.requestFocus,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -317,16 +331,30 @@ class _TextInputDialogState extends State<_TextInputDialog>
|
||||
title: Text(widget.title),
|
||||
content: FocusableTextField(
|
||||
controller: _controller,
|
||||
focusNode: _fieldFocusNode,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(labelText: widget.labelText, hintText: widget.hintText),
|
||||
keyboardType: widget.keyboardType,
|
||||
inputFormatters: widget.inputFormatters,
|
||||
textInputAction: TextInputAction.done,
|
||||
onNavigateDown: _saveFocusNode.requestFocus,
|
||||
onSubmitted: (_) => _saveFocusNode.requestFocus(),
|
||||
),
|
||||
actions: [
|
||||
DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel),
|
||||
DialogActionButton(onPressed: _submit, label: widget.confirmText ?? t.common.save, focusNode: _saveFocusNode),
|
||||
DialogActionButton(
|
||||
focusNode: _cancelFocusNode,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
onNavigateUp: _fieldFocusNode.requestFocus,
|
||||
onNavigateRight: _saveFocusNode.requestFocus,
|
||||
label: t.common.cancel,
|
||||
),
|
||||
DialogActionButton(
|
||||
onPressed: _submit,
|
||||
label: widget.confirmText ?? t.common.save,
|
||||
focusNode: _saveFocusNode,
|
||||
onNavigateUp: _fieldFocusNode.requestFocus,
|
||||
onNavigateLeft: _cancelFocusNode.requestFocus,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -301,6 +301,9 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
|
||||
|
||||
Future<void> _renameRoom(RecentRoom room) async {
|
||||
final controller = TextEditingController(text: room.name ?? '');
|
||||
final fieldFocusNode = FocusNode(debugLabel: 'WatchTogetherRenameField');
|
||||
final cancelFocusNode = FocusNode(debugLabel: 'WatchTogetherRenameCancel');
|
||||
final saveFocusNode = FocusNode(debugLabel: 'WatchTogetherRenameSave');
|
||||
String? name;
|
||||
try {
|
||||
name = await showDialog<String>(
|
||||
@@ -309,18 +312,36 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
|
||||
title: Text(t.watchTogether.renameRoom),
|
||||
content: FocusableTextField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(hintText: room.code),
|
||||
onNavigateDown: saveFocusNode.requestFocus,
|
||||
onSubmitted: (value) => Navigator.pop(context, value),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
|
||||
FilledButton(onPressed: () => Navigator.pop(context, controller.text), child: Text(t.common.save)),
|
||||
DialogActionButton(
|
||||
focusNode: cancelFocusNode,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
onNavigateUp: fieldFocusNode.requestFocus,
|
||||
onNavigateRight: saveFocusNode.requestFocus,
|
||||
label: t.common.cancel,
|
||||
),
|
||||
DialogActionButton(
|
||||
focusNode: saveFocusNode,
|
||||
onPressed: () => Navigator.pop(context, controller.text),
|
||||
onNavigateUp: fieldFocusNode.requestFocus,
|
||||
onNavigateLeft: cancelFocusNode.requestFocus,
|
||||
isPrimary: true,
|
||||
label: t.common.save,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
controller.dispose();
|
||||
fieldFocusNode.dispose();
|
||||
cancelFocusNode.dispose();
|
||||
saveFocusNode.dispose();
|
||||
}
|
||||
if (name == null || !mounted) return;
|
||||
|
||||
|
||||
@@ -18,6 +18,17 @@ class JoinSessionDialog extends StatefulWidget {
|
||||
class _JoinSessionDialogState extends State<JoinSessionDialog> with ControllerDisposerMixin {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final _sessionIdController = createTextEditingController();
|
||||
final _closeFocusNode = FocusNode(debugLabel: 'JoinSessionClose');
|
||||
final _sessionIdFocusNode = FocusNode(debugLabel: 'JoinSessionCode');
|
||||
final _joinFocusNode = FocusNode(debugLabel: 'JoinSessionSubmit');
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_closeFocusNode.dispose();
|
||||
_sessionIdFocusNode.dispose();
|
||||
_joinFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -40,10 +51,13 @@ class _JoinSessionDialogState extends State<JoinSessionDialog> with ControllerDi
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(t.watchTogether.joinWatchSession, style: theme.textTheme.titleLarge)),
|
||||
FocusableWrapper(
|
||||
focusNode: _closeFocusNode,
|
||||
useBackgroundFocus: true,
|
||||
disableScale: true,
|
||||
borderRadius: 20,
|
||||
descendantsAreFocusable: false,
|
||||
onSelect: () => Navigator.of(context).pop(),
|
||||
onNavigateDown: _sessionIdFocusNode.requestFocus,
|
||||
child: IconButton(onPressed: () => Navigator.of(context).pop(), icon: const Icon(Symbols.close)),
|
||||
),
|
||||
],
|
||||
@@ -53,6 +67,7 @@ class _JoinSessionDialogState extends State<JoinSessionDialog> with ControllerDi
|
||||
|
||||
FocusableTextFormField(
|
||||
controller: _sessionIdController,
|
||||
focusNode: _sessionIdFocusNode,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.watchTogether.sessionCode,
|
||||
hintText: t.watchTogether.enterCodeHint,
|
||||
@@ -79,6 +94,8 @@ class _JoinSessionDialogState extends State<JoinSessionDialog> with ControllerDi
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onNavigateUp: _closeFocusNode.requestFocus,
|
||||
onNavigateDown: _joinFocusNode.requestFocus,
|
||||
onFieldSubmitted: (_) => _join(),
|
||||
autofocus: true,
|
||||
),
|
||||
@@ -93,7 +110,9 @@ class _JoinSessionDialogState extends State<JoinSessionDialog> with ControllerDi
|
||||
const SizedBox(height: 24),
|
||||
|
||||
FocusableButton(
|
||||
focusNode: _joinFocusNode,
|
||||
onPressed: _join,
|
||||
onNavigateUp: _sessionIdFocusNode.requestFocus,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _join,
|
||||
icon: const Icon(Symbols.group_add),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../screens/settings/add_connection_screen.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import 'app_icon.dart';
|
||||
|
||||
/// Top-of-app banner shown when one or more servers' tokens have been
|
||||
@@ -53,13 +54,16 @@ class AuthErrorBanner extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.tonal(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: scheme.onErrorContainer,
|
||||
foregroundColor: scheme.errorContainer,
|
||||
),
|
||||
FocusableButton(
|
||||
onPressed: () => _openReauth(context),
|
||||
child: Text(t.connections.signInAgain),
|
||||
child: FilledButton.tonal(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: scheme.onErrorContainer,
|
||||
foregroundColor: scheme.errorContainer,
|
||||
),
|
||||
onPressed: () => _openReauth(context),
|
||||
child: Text(t.connections.signInAgain),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -4,7 +4,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../connection/connection_registry.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../focus/focusable_text_field.dart';
|
||||
import '../../focus/focusable_wrapper.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../mixins/controller_disposer_mixin.dart';
|
||||
import '../../mixins/mounted_set_state_mixin.dart';
|
||||
@@ -27,6 +29,9 @@ class DiscoveryView extends StatefulWidget {
|
||||
|
||||
class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMixin, MountedSetStateMixin {
|
||||
late final _hostAddressController = createTextEditingController();
|
||||
final _manualToggleFocusNode = FocusNode(debugLabel: 'CompanionManualToggle');
|
||||
final _hostAddressFocusNode = FocusNode(debugLabel: 'CompanionHostAddress');
|
||||
final _connectFocusNode = FocusNode(debugLabel: 'CompanionConnect');
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isConnecting = false;
|
||||
String? _errorMessage;
|
||||
@@ -111,6 +116,9 @@ class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMi
|
||||
_discoverySubscription?.cancel();
|
||||
_searchTimeout?.cancel();
|
||||
_provider.stopDiscovery();
|
||||
_manualToggleFocusNode.dispose();
|
||||
_hostAddressFocusNode.dispose();
|
||||
_connectFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -284,20 +292,35 @@ class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMi
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => setState(() => _showManualEntry = !_showManualEntry),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_showManualEntry ? Icons.expand_less : Icons.expand_more,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
FocusableWrapper(
|
||||
focusNode: _manualToggleFocusNode,
|
||||
useBackgroundFocus: true,
|
||||
disableScale: true,
|
||||
borderRadius: 8,
|
||||
onSelect: () => setState(() => _showManualEntry = !_showManualEntry),
|
||||
onNavigateDown: _showManualEntry ? _hostAddressFocusNode.requestFocus : null,
|
||||
child: InkWell(
|
||||
canRequestFocus: false,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
onTap: () => setState(() => _showManualEntry = !_showManualEntry),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_showManualEntry ? Icons.expand_less : Icons.expand_more,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
t.companionRemote.pairing.manualConnection,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
t.companionRemote.pairing.manualConnection,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_showManualEntry) ...[
|
||||
@@ -309,6 +332,7 @@ class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMi
|
||||
children: [
|
||||
FocusableTextFormField(
|
||||
controller: _hostAddressController,
|
||||
focusNode: _hostAddressFocusNode,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.companionRemote.session.hostAddress,
|
||||
hintText: t.companionRemote.pairing.hostAddressHint,
|
||||
@@ -325,17 +349,29 @@ class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMi
|
||||
return null;
|
||||
},
|
||||
enabled: !_isConnecting,
|
||||
onNavigateUp: _manualToggleFocusNode.requestFocus,
|
||||
onNavigateDown: _connectFocusNode.requestFocus,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
FocusableButton(
|
||||
focusNode: _connectFocusNode,
|
||||
onNavigateUp: _hostAddressFocusNode.requestFocus,
|
||||
onPressed: _isConnecting
|
||||
? null
|
||||
: () {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
_connect(() => _provider.connectToManualHost(_hostAddressController.text.trim()));
|
||||
},
|
||||
icon: _isConnecting ? const LoadingIndicatorBox(size: 16) : const Icon(Icons.link),
|
||||
label: Text(_isConnecting ? t.companionRemote.pairing.connecting : t.common.connect),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _isConnecting
|
||||
? null
|
||||
: () {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
_connect(() => _provider.connectToManualHost(_hostAddressController.text.trim()));
|
||||
},
|
||||
icon: _isConnecting ? const LoadingIndicatorBox(size: 16) : const Icon(Icons.link),
|
||||
label: Text(_isConnecting ? t.companionRemote.pairing.connecting : t.common.connect),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../models/trackers/device_code.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
@@ -63,10 +64,13 @@ class DeviceCodeDialog extends StatelessWidget {
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: Text(t.trackers.deviceCode.openToActivate(service: serviceName)),
|
||||
child: FocusableButton(
|
||||
onPressed: _open,
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: Text(t.trackers.deviceCode.openToActivate(service: serviceName)),
|
||||
onPressed: _open,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -13,6 +13,10 @@ class DialogActionButton extends StatelessWidget {
|
||||
final String label;
|
||||
final FocusNode? focusNode;
|
||||
final bool isPrimary;
|
||||
final VoidCallback? onNavigateUp;
|
||||
final VoidCallback? onNavigateDown;
|
||||
final VoidCallback? onNavigateLeft;
|
||||
final VoidCallback? onNavigateRight;
|
||||
|
||||
const DialogActionButton({
|
||||
super.key,
|
||||
@@ -20,6 +24,10 @@ class DialogActionButton extends StatelessWidget {
|
||||
required this.label,
|
||||
this.focusNode,
|
||||
this.isPrimary = false,
|
||||
this.onNavigateUp,
|
||||
this.onNavigateDown,
|
||||
this.onNavigateLeft,
|
||||
this.onNavigateRight,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -27,6 +35,11 @@ class DialogActionButton extends StatelessWidget {
|
||||
return FocusableButton(
|
||||
focusNode: focusNode,
|
||||
onPressed: onPressed,
|
||||
useBackgroundFocus: isPrimary,
|
||||
onNavigateUp: onNavigateUp,
|
||||
onNavigateDown: onNavigateDown,
|
||||
onNavigateLeft: onNavigateLeft,
|
||||
onNavigateRight: onNavigateRight,
|
||||
child: isPrimary
|
||||
? FilledButton(onPressed: onPressed, child: Text(label))
|
||||
: TextButton(onPressed: onPressed, child: Text(label)),
|
||||
|
||||
@@ -129,6 +129,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
|
||||
key: Key(widget.episode.id),
|
||||
borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius),
|
||||
onTap: widget.onTap,
|
||||
canRequestFocus: false,
|
||||
onTapDown: storeTapPosition,
|
||||
onLongPress: showContextMenuFromTap,
|
||||
onSecondaryTapDown: storeTapPosition,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../focus/focusable_wrapper.dart';
|
||||
|
||||
/// A [PopupMenuButton] that can be focused and opened with D-pad select.
|
||||
class FocusablePopupMenuButton<T> extends StatefulWidget {
|
||||
final Widget? icon;
|
||||
final String? tooltip;
|
||||
final PopupMenuItemBuilder<T> itemBuilder;
|
||||
final PopupMenuItemSelected<T>? onSelected;
|
||||
final GlobalKey<PopupMenuButtonState<T>>? menuKey;
|
||||
final FocusNode? focusNode;
|
||||
final VoidCallback? onNavigateUp;
|
||||
final VoidCallback? onNavigateDown;
|
||||
final VoidCallback? onNavigateLeft;
|
||||
final VoidCallback? onNavigateRight;
|
||||
final String? semanticLabel;
|
||||
final double borderRadius;
|
||||
final bool useBackgroundFocus;
|
||||
final bool enableLongPress;
|
||||
|
||||
const FocusablePopupMenuButton({
|
||||
super.key,
|
||||
this.icon,
|
||||
this.tooltip,
|
||||
required this.itemBuilder,
|
||||
this.onSelected,
|
||||
this.menuKey,
|
||||
this.focusNode,
|
||||
this.onNavigateUp,
|
||||
this.onNavigateDown,
|
||||
this.onNavigateLeft,
|
||||
this.onNavigateRight,
|
||||
this.semanticLabel,
|
||||
this.borderRadius = 100,
|
||||
this.useBackgroundFocus = true,
|
||||
this.enableLongPress = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FocusablePopupMenuButton<T>> createState() => _FocusablePopupMenuButtonState<T>();
|
||||
}
|
||||
|
||||
class _FocusablePopupMenuButtonState<T> extends State<FocusablePopupMenuButton<T>> {
|
||||
final _ownedMenuKey = GlobalKey<PopupMenuButtonState<T>>();
|
||||
|
||||
GlobalKey<PopupMenuButtonState<T>> get _menuKey => widget.menuKey ?? _ownedMenuKey;
|
||||
|
||||
void _showMenu() => _menuKey.currentState?.showButtonMenu();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FocusableWrapper(
|
||||
focusNode: widget.focusNode,
|
||||
disableScale: true,
|
||||
borderRadius: widget.borderRadius,
|
||||
useBackgroundFocus: widget.useBackgroundFocus,
|
||||
descendantsAreFocusable: false,
|
||||
semanticLabel: widget.semanticLabel ?? widget.tooltip,
|
||||
enableLongPress: widget.enableLongPress,
|
||||
onNavigateUp: widget.onNavigateUp,
|
||||
onNavigateDown: widget.onNavigateDown,
|
||||
onNavigateLeft: widget.onNavigateLeft,
|
||||
onNavigateRight: widget.onNavigateRight,
|
||||
onSelect: _showMenu,
|
||||
onLongPress: widget.enableLongPress ? _showMenu : null,
|
||||
child: PopupMenuButton<T>(
|
||||
key: _menuKey,
|
||||
icon: widget.icon,
|
||||
tooltip: widget.tooltip,
|
||||
requestFocus: true,
|
||||
onSelected: widget.onSelected,
|
||||
itemBuilder: widget.itemBuilder,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -65,10 +65,11 @@ class _FocusedScrollScaffoldState extends State<FocusedScrollScaffold> {
|
||||
void _requestInitialFocus() {
|
||||
if (_focusRequested || !mounted || !InputModeTracker.isKeyboardMode(context)) return;
|
||||
_focusRequested = true;
|
||||
_scopeNode.requestFocus();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
primaryFocus?.nextFocus();
|
||||
if (_scopeNode.focusedChild != null) return;
|
||||
_scopeNode.requestFocus();
|
||||
_scopeNode.nextFocus();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1553,8 +1553,17 @@ class _CollectionSelectionDialog extends StatefulWidget {
|
||||
|
||||
class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog> with ControllerDisposerMixin {
|
||||
late final _filterController = createTextEditingController();
|
||||
final _filterFocusNode = FocusNode(debugLabel: 'CollectionFilter');
|
||||
final _firstCollectionFocusNode = FocusNode(debugLabel: 'CollectionFirstItem');
|
||||
late List<MediaItem> _filteredCollections = widget.collections;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_filterFocusNode.dispose();
|
||||
_firstCollectionFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFilterChanged(String query) {
|
||||
final lower = query.toLowerCase();
|
||||
setState(() {
|
||||
@@ -1576,7 +1585,9 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog>
|
||||
if (widget.collections.length >= 10) ...[
|
||||
FocusableTextField(
|
||||
controller: _filterController,
|
||||
focusNode: _filterFocusNode,
|
||||
autofocus: true,
|
||||
onNavigateDown: _firstCollectionFocusNode.requestFocus,
|
||||
decoration: pillInputDecoration(
|
||||
context,
|
||||
hintText: t.collections.searchCollections,
|
||||
@@ -1592,7 +1603,9 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog>
|
||||
itemCount: _filteredCollections.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return ListTile(
|
||||
return FocusableListTile(
|
||||
focusNode: _firstCollectionFocusNode,
|
||||
autofocus: widget.collections.length < 10,
|
||||
leading: const AppIcon(Symbols.add_rounded, fill: 1),
|
||||
title: Text(t.common.createNew),
|
||||
onTap: () => Navigator.pop(context, '_create_new'),
|
||||
@@ -1600,7 +1613,7 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog>
|
||||
}
|
||||
|
||||
final collection = _filteredCollections[index - 1];
|
||||
return ListTile(
|
||||
return FocusableListTile(
|
||||
leading: const AppIcon(Symbols.collections_rounded, fill: 1),
|
||||
title: Text(collection.title ?? ''),
|
||||
subtitle: collection.childCount != null
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../services/trackers/oauth_proxy_client.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import 'dialog_action_button.dart';
|
||||
@@ -72,10 +73,13 @@ class OAuthProxyDialog extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: Text(t.trackers.oauthProxy.openToSignIn(service: serviceName)),
|
||||
child: FocusableButton(
|
||||
onPressed: _open,
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: Text(t.trackers.oauthProxy.openToSignIn(service: serviceName)),
|
||||
onPressed: _open,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../widgets/app_icon.dart';
|
||||
import '../widgets/overlay_sheet.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../utils/formatters.dart';
|
||||
@@ -111,24 +112,38 @@ class _RatingBottomSheetState extends State<RatingBottomSheet> {
|
||||
children: [
|
||||
if (widget.currentRating > 0)
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
child: FocusableButton(
|
||||
onPressed: () {
|
||||
widget.onClear();
|
||||
OverlaySheetController.closeAdaptive(context);
|
||||
},
|
||||
child: Text(t.common.clear),
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
widget.onClear();
|
||||
OverlaySheetController.closeAdaptive(context);
|
||||
},
|
||||
child: Text(t.common.clear),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.currentRating > 0) const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
child: FocusableButton(
|
||||
onPressed: _selectedRating > 0
|
||||
? () {
|
||||
widget.onRate(_selectedRating);
|
||||
OverlaySheetController.closeAdaptive(context);
|
||||
}
|
||||
: null,
|
||||
child: Text(t.mediaMenu.rate),
|
||||
child: FilledButton(
|
||||
onPressed: _selectedRating > 0
|
||||
? () {
|
||||
widget.onRate(_selectedRating);
|
||||
OverlaySheetController.closeAdaptive(context);
|
||||
}
|
||||
: null,
|
||||
child: Text(t.mediaMenu.rate),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../focus/focusable_button.dart';
|
||||
import '../../../services/sleep_timer_service.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../../utils/formatters.dart';
|
||||
@@ -33,26 +34,37 @@ class SleepTimerActiveStatus extends StatelessWidget {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
icon: const AppIcon(Symbols.add_rounded, fill: 1),
|
||||
label: Text(t.videoControls.addTime(amount: "15", unit: " min")),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.outline),
|
||||
),
|
||||
FocusableButton(
|
||||
onPressed: () {
|
||||
sleepTimer.extendTimer(const Duration(minutes: 15));
|
||||
},
|
||||
child: OutlinedButton.icon(
|
||||
icon: const AppIcon(Symbols.add_rounded, fill: 1),
|
||||
label: Text(t.videoControls.addTime(amount: "15", unit: " min")),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.outline),
|
||||
),
|
||||
onPressed: () {
|
||||
sleepTimer.extendTimer(const Duration(minutes: 15));
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
icon: const AppIcon(Symbols.cancel_rounded, fill: 1),
|
||||
label: Text(t.common.cancel),
|
||||
style: FilledButton.styleFrom(backgroundColor: Colors.red),
|
||||
FocusableButton(
|
||||
onPressed: () {
|
||||
sleepTimer.cancelTimer();
|
||||
onCancel?.call();
|
||||
},
|
||||
child: FilledButton.icon(
|
||||
icon: const AppIcon(Symbols.cancel_rounded, fill: 1),
|
||||
label: Text(t.common.cancel),
|
||||
style: FilledButton.styleFrom(backgroundColor: Colors.red),
|
||||
onPressed: () {
|
||||
sleepTimer.cancelTimer();
|
||||
onCancel?.call();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../focus/dpad_navigator.dart';
|
||||
import '../../../focus/focusable_button.dart';
|
||||
import '../../../focus/focusable_wrapper.dart';
|
||||
import '../../../mpv/mpv.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
@@ -163,19 +164,25 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
|
||||
double size = 48,
|
||||
double iconSize = 28,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
onLongPressStart: (_) => onLongPressStart(),
|
||||
onLongPressEnd: (_) => _stopLongPress(),
|
||||
onLongPressCancel: _stopLongPress,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
return FocusableWrapper(
|
||||
onSelect: onTap,
|
||||
borderRadius: 18,
|
||||
autoScroll: false,
|
||||
useBackgroundFocus: true,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
onLongPressStart: (_) => onLongPressStart(),
|
||||
onLongPressEnd: (_) => _stopLongPress(),
|
||||
onLongPressCancel: _stopLongPress,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
child: Icon(icon, color: tokens(context).text, size: iconSize),
|
||||
),
|
||||
child: Icon(icon, color: tokens(context).text, size: iconSize),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -358,11 +365,14 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Reset button
|
||||
ElevatedButton.icon(
|
||||
FocusableButton(
|
||||
onPressed: _currentOffset != 0 ? _resetOffset : null,
|
||||
icon: const AppIcon(Symbols.restart_alt_rounded, fill: 1),
|
||||
label: Text(t.videoControls.resetToZero),
|
||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _currentOffset != 0 ? _resetOffset : null,
|
||||
icon: const AppIcon(Symbols.restart_alt_rounded, fill: 1),
|
||||
label: Text(t.videoControls.resetToZero),
|
||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/focus/input_mode_tracker.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/screens/profile/add_local_profile_screen.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
|
||||
import '../../test_helpers/prefs.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
resetSharedPreferencesForTest();
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
});
|
||||
|
||||
testWidgets('D-pad leaves profile name input and reaches actions', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
child: const InputModeTracker(child: MaterialApp(home: AddLocalProfileScreen())),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:Name');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:SetPin');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:Continue');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:Cancel');
|
||||
});
|
||||
|
||||
testWidgets('remote back pops the new profile page', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
child: InputModeTracker(
|
||||
child: MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const AddLocalProfileScreen())),
|
||||
child: const Text('Open new profile'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open new profile'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(t.profiles.newProfile), findsOneWidget);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Open new profile'), findsOneWidget);
|
||||
expect(find.text(t.profiles.newProfile), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/connection/connection.dart';
|
||||
import 'package:plezy/connection/connection_registry.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/focus/input_mode_tracker.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/profiles/plex_home_service.dart';
|
||||
import 'package:plezy/profiles/profile.dart';
|
||||
import 'package:plezy/profiles/profile_connection.dart';
|
||||
import 'package:plezy/profiles/profile_connection_registry.dart';
|
||||
import 'package:plezy/profiles/profile_registry.dart';
|
||||
import 'package:plezy/screens/profile/profile_detail_screen.dart';
|
||||
import 'package:plezy/services/storage_service.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../test_helpers/prefs.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
resetSharedPreferencesForTest();
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
});
|
||||
|
||||
testWidgets('remote back pops the manage profile page', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
final profile = Profile(
|
||||
id: 'local-owner',
|
||||
kind: ProfileKind.local,
|
||||
displayName: 'Owner',
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
final profiles = ProfileRegistry(db);
|
||||
final connections = _FakeConnectionRegistry(db);
|
||||
final profileConnections = _FakeProfileConnectionRegistry(db);
|
||||
final storage = await StorageService.getInstance();
|
||||
final plexHome = PlexHomeService(
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
plexHomeUserFetcher: (_) async => const [],
|
||||
);
|
||||
addTearDown(() async {
|
||||
await plexHome.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
Provider<ProfileRegistry>.value(value: profiles),
|
||||
Provider<ProfileConnectionRegistry>.value(value: profileConnections),
|
||||
Provider<ConnectionRegistry>.value(value: connections),
|
||||
Provider<PlexHomeService>.value(value: plexHome),
|
||||
],
|
||||
child: InputModeTracker(
|
||||
child: MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => ProfileDetailScreen(profile: profile)));
|
||||
},
|
||||
child: const Text('Open profile'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open profile'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(t.profiles.connectionsLabel), findsOneWidget);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Open profile'), findsOneWidget);
|
||||
expect(find.text(t.profiles.connectionsLabel), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeConnectionRegistry extends ConnectionRegistry {
|
||||
_FakeConnectionRegistry(super.db);
|
||||
|
||||
@override
|
||||
Future<List<Connection>> list() async => const [];
|
||||
}
|
||||
|
||||
class _FakeProfileConnectionRegistry extends ProfileConnectionRegistry {
|
||||
_FakeProfileConnectionRegistry(super.db);
|
||||
|
||||
@override
|
||||
Stream<List<ProfileConnection>> watchForProfile(String profileId) => Stream.value(const []);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/connection/connection.dart';
|
||||
import 'package:plezy/connection/connection_registry.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/profiles/active_profile_provider.dart';
|
||||
import 'package:plezy/profiles/plex_home_service.dart';
|
||||
import 'package:plezy/profiles/profile.dart';
|
||||
import 'package:plezy/profiles/profile_connection.dart';
|
||||
import 'package:plezy/profiles/profile_connection_registry.dart';
|
||||
import 'package:plezy/profiles/profile_registry.dart';
|
||||
import 'package:plezy/screens/profile/profile_switch_screen.dart';
|
||||
import 'package:plezy/services/storage_service.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../test_helpers/prefs.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
resetSharedPreferencesForTest();
|
||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||
});
|
||||
|
||||
testWidgets('D-pad can focus profile actions and open the manage menu', (tester) async {
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
final profile = Profile(
|
||||
id: 'local-owner',
|
||||
kind: ProfileKind.local,
|
||||
displayName: 'Owner',
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
final profiles = _FakeProfileRegistry(db, [profile]);
|
||||
final connections = _FakeConnectionRegistry(db);
|
||||
final profileConnections = _FakeProfileConnectionRegistry(db);
|
||||
final storage = await StorageService.getInstance();
|
||||
final plexHome = PlexHomeService(
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
plexHomeUserFetcher: (_) async => const [],
|
||||
);
|
||||
final activeProfile = ActiveProfileProvider(
|
||||
registry: profiles,
|
||||
plexHome: plexHome,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
);
|
||||
addTearDown(() async {
|
||||
activeProfile.dispose();
|
||||
await plexHome.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
Provider<ProfileRegistry>.value(value: profiles),
|
||||
Provider<ProfileConnectionRegistry>.value(value: profileConnections),
|
||||
Provider<ConnectionRegistry>.value(value: connections),
|
||||
Provider<PlexHomeService>.value(value: plexHome),
|
||||
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfile),
|
||||
],
|
||||
child: const MaterialApp(home: ProfileSwitchScreen()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Owner'), findsOneWidget);
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ProfileTile:local-owner');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ProfileActions:local-owner');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(t.profiles.manage), findsOneWidget);
|
||||
expect(find.text(t.profiles.delete), findsOneWidget);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump();
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeProfileRegistry extends ProfileRegistry {
|
||||
final List<Profile> _profiles;
|
||||
|
||||
_FakeProfileRegistry(super.db, this._profiles);
|
||||
|
||||
@override
|
||||
Stream<List<Profile>> watchProfiles() => Stream.value(_profiles);
|
||||
|
||||
@override
|
||||
Future<List<Profile>> list() async => _profiles;
|
||||
}
|
||||
|
||||
class _FakeConnectionRegistry extends ConnectionRegistry {
|
||||
_FakeConnectionRegistry(super.db);
|
||||
|
||||
@override
|
||||
Stream<List<Connection>> watchConnections() => Stream.value(const []);
|
||||
|
||||
@override
|
||||
Future<List<Connection>> list() async => const [];
|
||||
}
|
||||
|
||||
class _FakeProfileConnectionRegistry extends ProfileConnectionRegistry {
|
||||
_FakeProfileConnectionRegistry(super.db);
|
||||
|
||||
@override
|
||||
Stream<List<ProfileConnection>> watchAll() => Stream.value(const []);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/focus/input_mode_tracker.dart';
|
||||
import 'package:plezy/profiles/profile.dart';
|
||||
import 'package:plezy/screens/settings/add_jellyfin_screen.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
|
||||
Profile _profile(String id) => Profile(
|
||||
id: id,
|
||||
@@ -12,6 +14,10 @@ Profile _profile(String id) => Profile(
|
||||
);
|
||||
|
||||
void main() {
|
||||
tearDown(() {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
});
|
||||
|
||||
testWidgets('autofocuses the server URL field', (tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: AddJellyfinScreen()));
|
||||
await tester.pump();
|
||||
@@ -21,6 +27,15 @@ void main() {
|
||||
expect(field.autofocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('TV initial focus stays on the server URL field', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
|
||||
await tester.pumpWidget(const InputModeTracker(child: MaterialApp(home: AddJellyfinScreen())));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url');
|
||||
});
|
||||
|
||||
group('Jellyfin profile binding decisions', () {
|
||||
test('creates a local profile only on true first-run with no profiles', () {
|
||||
expect(shouldCreateLocalJellyfinProfile(targetProfile: null, activeProfile: null, hasProfiles: false), isTrue);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/widgets/focusable_popup_menu_button.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('D-pad select opens the popup menu', (tester) async {
|
||||
final focusNode = FocusNode(debugLabel: 'test_popup_menu');
|
||||
addTearDown(focusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: FocusablePopupMenuButton<String>(
|
||||
focusNode: focusNode,
|
||||
icon: const Icon(Icons.more_vert),
|
||||
itemBuilder: (_) => const [PopupMenuItem(value: 'one', child: Text('One'))],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('One'), findsNothing);
|
||||
|
||||
focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('One'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user