Apple TV single-line fields moved to the engine's UITextField proxy in
2.10.0 (71735354), which made three focus behaviours user-visible.
Submitting re-attached the input connection. EditableText schedules a
restart when a submit action fires with a non-null onSubmitted, and that
microtask runs before the setState flipping readOnly, so the field
re-showed a keyboard the form had just dismissed. The native path now
withholds onSubmitted from EditableText and invokes it from the host,
independently of onEditingComplete as _finalizeEditing does.
Auto-open fired on every focus entry, so D-pad traversal of a multi-field
form raised and dismissed the modal system keyboard on each step.
TvTextInputAutoOpenBehavior gains onFirstFocus, and the new `automatic`
default resolves to it on Apple TV: arriving at a field opens it once,
returning to it does not. Android TV keeps its docked-IME auto-open, and
explicit modes stay literal on both. The autofocused Jellyfin and Seerr
URL fields keep an explicit exception so entering the screen still does
not bury the form (#1217).
EditableText.connectionClosed unfocuses the field outright, so a UIKit
keyboard dismissal left nothing focused at all. The host takes focus back,
keyed on identity with the field's own enclosing scope so a dialog or
route claiming focus meanwhile is left alone.
close #1728
369 lines
14 KiB
Dart
369 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../focus/focusable_button.dart';
|
|
import '../../focus/focusable_text_field.dart';
|
|
import '../../i18n/strings.g.dart';
|
|
import '../../mixins/controller_disposer_mixin.dart';
|
|
import '../../models/seerr/seerr_public_settings.dart';
|
|
import '../../models/seerr/seerr_session.dart';
|
|
import '../../providers/seerr_account_provider.dart';
|
|
import '../../services/seerr/seerr_constants.dart';
|
|
import '../../services/seerr/seerr_exceptions.dart';
|
|
import '../../theme/mono_tokens.dart';
|
|
import '../../widgets/app_icon.dart';
|
|
import '../../widgets/focused_scroll_scaffold.dart';
|
|
import '../../widgets/loading_indicator_box.dart';
|
|
import 'async_form_state_mixin.dart';
|
|
|
|
/// Which credential form is on screen after the probe.
|
|
enum _CredentialForm { none, jellyfin, emby, local }
|
|
|
|
/// Two-step Seerr connect flow:
|
|
/// 1. Probe the instance URL (`/settings/public`).
|
|
/// 2. Sign in with one of the methods the instance supports — one-tap
|
|
/// Plex (reusing the profile's stored token), Jellyfin/Emby
|
|
/// credentials, or a local Seerr account.
|
|
///
|
|
/// The finished [SeerrSession] is handed to [SeerrAccountProvider.adoptSession]
|
|
/// and the screen pops.
|
|
class SeerrConnectScreen extends StatefulWidget {
|
|
const SeerrConnectScreen({super.key});
|
|
|
|
@override
|
|
State<SeerrConnectScreen> createState() => _SeerrConnectScreenState();
|
|
}
|
|
|
|
class _SeerrConnectScreenState extends State<SeerrConnectScreen> with AsyncFormStateMixin, ControllerDisposerMixin {
|
|
late final _urlController = createTextEditingController();
|
|
late final _identifierController = createTextEditingController();
|
|
late final _passwordController = createTextEditingController();
|
|
final _urlFocus = FocusNode(debugLabel: 'SeerrConnect:Url');
|
|
final _continueFocus = FocusNode(debugLabel: 'SeerrConnect:Continue');
|
|
final _changeServerFocus = FocusNode(debugLabel: 'SeerrConnect:ChangeServer');
|
|
final _identifierFocus = FocusNode(debugLabel: 'SeerrConnect:Identifier');
|
|
final _passwordFocus = FocusNode(debugLabel: 'SeerrConnect:Password');
|
|
final _formKey = GlobalKey<FormState>();
|
|
|
|
SeerrPublicSettings? _instance;
|
|
String _baseUrl = '';
|
|
bool _plexTokenAvailable = false;
|
|
_CredentialForm _form = _CredentialForm.none;
|
|
|
|
@override
|
|
void dispose() {
|
|
_urlFocus.dispose();
|
|
_continueFocus.dispose();
|
|
_changeServerFocus.dispose();
|
|
_identifierFocus.dispose();
|
|
_passwordFocus.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
bool get _offersPlex {
|
|
final instance = _instance;
|
|
return instance != null &&
|
|
instance.mediaServerLogin &&
|
|
instance.mediaServerType == SeerrMediaServerType.plex &&
|
|
_plexTokenAvailable;
|
|
}
|
|
|
|
_CredentialForm get _mediaServerForm {
|
|
final instance = _instance;
|
|
if (instance == null || !instance.mediaServerLogin) return _CredentialForm.none;
|
|
return switch (instance.mediaServerType) {
|
|
SeerrMediaServerType.jellyfin => _CredentialForm.jellyfin,
|
|
SeerrMediaServerType.emby => _CredentialForm.emby,
|
|
_ => _CredentialForm.none,
|
|
};
|
|
}
|
|
|
|
Future<void> _probe() async {
|
|
final input = _urlController.text.trim();
|
|
if (input.isEmpty) {
|
|
setErrorText(t.addServer.required);
|
|
return;
|
|
}
|
|
// Bare hosts are common ("seerr.example.com") — default to https.
|
|
final url = input.contains('://') ? input : 'https://$input';
|
|
await runAsync<void>(() async {
|
|
final account = context.read<SeerrAccountProvider>();
|
|
final settings = await account.authService.probe(url);
|
|
final plexToken = await account.resolvePlexToken();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_instance = settings;
|
|
_baseUrl = url;
|
|
_plexTokenAvailable = plexToken != null && plexToken.isNotEmpty;
|
|
// With exactly one credential form on offer, skip the method list.
|
|
final mediaForm = _mediaServerForm;
|
|
if (!_offersPlex && mediaForm != _CredentialForm.none && !settings.localLogin) {
|
|
_form = mediaForm;
|
|
} else if (!_offersPlex && mediaForm == _CredentialForm.none && settings.localLogin) {
|
|
_form = _CredentialForm.local;
|
|
} else {
|
|
_form = _CredentialForm.none;
|
|
}
|
|
});
|
|
}, errorMapper: _describeError);
|
|
}
|
|
|
|
Future<void> _signInWithPlex() async {
|
|
await runAsync<void>(() async {
|
|
final account = context.read<SeerrAccountProvider>();
|
|
final token = await account.resolvePlexToken();
|
|
if (token == null || token.isEmpty) throw const SeerrAuthException('No Plex token available');
|
|
final session = await account.authService.signInWithPlex(baseUrl: _baseUrl, plexToken: token);
|
|
await _finish(account, session);
|
|
}, errorMapper: _describeError);
|
|
}
|
|
|
|
Future<void> _signInWithCredentials() async {
|
|
if (!(_formKey.currentState?.validate() ?? false)) return;
|
|
final form = _form;
|
|
await runAsync<void>(() async {
|
|
final account = context.read<SeerrAccountProvider>();
|
|
final identifier = _identifierController.text.trim();
|
|
final password = _passwordController.text;
|
|
final session = switch (form) {
|
|
_CredentialForm.jellyfin || _CredentialForm.emby => await account.authService.signInWithJellyfin(
|
|
baseUrl: _baseUrl,
|
|
username: identifier,
|
|
password: password,
|
|
emby: form == _CredentialForm.emby,
|
|
),
|
|
_CredentialForm.local => await account.authService.signInWithLocal(
|
|
baseUrl: _baseUrl,
|
|
email: identifier,
|
|
password: password,
|
|
),
|
|
_CredentialForm.none => throw StateError('no credential form selected'),
|
|
};
|
|
await _finish(account, session);
|
|
}, errorMapper: _describeError);
|
|
}
|
|
|
|
Future<void> _finish(SeerrAccountProvider account, SeerrSession session) async {
|
|
await account.adoptSession(session.copyWith(instanceLabel: _instance?.instanceLabel));
|
|
if (!mounted) return;
|
|
Navigator.of(context).pop(true);
|
|
}
|
|
|
|
String _describeError(Object e) => switch (e) {
|
|
SeerrUrlException(:final message) => message,
|
|
SeerrAuthException(:final message) => message,
|
|
_ => t.addServer.couldNotReachServer(error: e.toString()),
|
|
};
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return FocusedScrollScaffold(
|
|
title: Text(t.seerr.connectTitle),
|
|
slivers: [
|
|
SliverPadding(
|
|
padding: const EdgeInsets.all(16),
|
|
sliver: SliverToBoxAdapter(
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: _instance == null ? _buildUrlStep(theme) : _buildSignInStep(theme),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
List<Widget> _buildUrlStep(ThemeData theme) {
|
|
return [
|
|
FocusableTextFormField(
|
|
controller: _urlController,
|
|
focusNode: _urlFocus,
|
|
autofocus: true,
|
|
tvTextInputAutoOpenBehavior: deferredUrlFieldAutoOpen,
|
|
keyboardType: TextInputType.url,
|
|
autocorrect: false,
|
|
enableSuggestions: false,
|
|
enabled: !busy,
|
|
onNavigateDown: () => _continueFocus.requestFocus(),
|
|
textInputAction: TextInputAction.go,
|
|
onFieldSubmitted: busy ? null : (_) => _probe(),
|
|
decoration: InputDecoration(
|
|
labelText: t.seerr.serverUrl,
|
|
// URL example — intentionally not localized.
|
|
hintText: 'https://seerr.example.com',
|
|
helperText: t.seerr.serverUrlHelper,
|
|
prefixIcon: const AppIcon(Symbols.link_rounded, fill: 1),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
FocusableButton(
|
|
focusNode: _continueFocus,
|
|
useBackgroundFocus: true,
|
|
onNavigateUp: () => _urlFocus.requestFocus(),
|
|
onPressed: busy ? null : _probe,
|
|
child: FilledButton.icon(
|
|
onPressed: busy ? null : _probe,
|
|
icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.travel_explore_rounded, fill: 1),
|
|
label: Text(t.seerr.checkServer),
|
|
),
|
|
),
|
|
...buildInlineError(theme),
|
|
];
|
|
}
|
|
|
|
List<Widget> _buildSignInStep(ThemeData theme) {
|
|
final instance = _instance!;
|
|
final mediaForm = _mediaServerForm;
|
|
final showLocalOption = instance.localLogin && _form != _CredentialForm.local;
|
|
final showMediaOption = mediaForm != _CredentialForm.none && _form != mediaForm;
|
|
final noMethods = !_offersPlex && mediaForm == _CredentialForm.none && !instance.localLogin;
|
|
return [
|
|
_buildInstanceCard(theme, instance),
|
|
const SizedBox(height: 16),
|
|
if (noMethods)
|
|
Text(t.seerr.noSignInMethods, style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.error))
|
|
else ...[
|
|
if (_offersPlex) ...[
|
|
FocusableButton(
|
|
useBackgroundFocus: true,
|
|
onPressed: busy ? null : _signInWithPlex,
|
|
child: FilledButton.icon(
|
|
onPressed: busy ? null : _signInWithPlex,
|
|
icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.login_rounded, fill: 1),
|
|
label: Text(t.auth.signInWithPlex),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
if (_form != _CredentialForm.none) ..._buildCredentialFields(theme),
|
|
if (showMediaOption)
|
|
_buildMethodButton(
|
|
label: mediaForm == _CredentialForm.emby ? t.seerr.signInWithEmby : t.seerr.signInWithJellyfin,
|
|
onPressed: () => _switchForm(mediaForm),
|
|
),
|
|
if (showLocalOption)
|
|
_buildMethodButton(label: t.seerr.signInWithLocal, onPressed: () => _switchForm(_CredentialForm.local)),
|
|
],
|
|
...buildInlineError(theme),
|
|
];
|
|
}
|
|
|
|
void _switchForm(_CredentialForm form) {
|
|
setState(() {
|
|
_form = form;
|
|
_identifierController.clear();
|
|
_passwordController.clear();
|
|
});
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted && _identifierFocus.canRequestFocus) _identifierFocus.requestFocus();
|
|
});
|
|
}
|
|
|
|
Widget _buildMethodButton({required String label, required VoidCallback onPressed}) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: FocusableButton(
|
|
useBackgroundFocus: true,
|
|
onPressed: busy ? null : onPressed,
|
|
child: OutlinedButton(onPressed: busy ? null : onPressed, child: Text(label)),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Widget> _buildCredentialFields(ThemeData theme) {
|
|
final isLocal = _form == _CredentialForm.local;
|
|
return [
|
|
FocusableTextFormField(
|
|
controller: _identifierController,
|
|
focusNode: _identifierFocus,
|
|
autocorrect: false,
|
|
enableSuggestions: false,
|
|
enabled: !busy,
|
|
keyboardType: isLocal ? TextInputType.emailAddress : TextInputType.text,
|
|
textInputAction: TextInputAction.next,
|
|
onFieldSubmitted: busy ? null : (_) => _passwordFocus.requestFocus(),
|
|
decoration: InputDecoration(
|
|
labelText: isLocal ? t.seerr.email : t.addServer.username,
|
|
prefixIcon: AppIcon(isLocal ? Symbols.mail_rounded : Symbols.person_rounded, fill: 1),
|
|
),
|
|
validator: (v) => v == null || v.trim().isEmpty ? t.addServer.required : null,
|
|
),
|
|
const SizedBox(height: 12),
|
|
FocusableTextFormField(
|
|
controller: _passwordController,
|
|
focusNode: _passwordFocus,
|
|
obscureText: true,
|
|
enabled: !busy,
|
|
textInputAction: TextInputAction.done,
|
|
onFieldSubmitted: busy ? null : (_) => _signInWithCredentials(),
|
|
decoration: InputDecoration(
|
|
labelText: t.addServer.password,
|
|
prefixIcon: const AppIcon(Symbols.lock_rounded, fill: 1),
|
|
),
|
|
validator: (v) => v == null || v.isEmpty ? t.addServer.required : null,
|
|
),
|
|
const SizedBox(height: 16),
|
|
FocusableButton(
|
|
useBackgroundFocus: true,
|
|
onPressed: busy ? null : _signInWithCredentials,
|
|
child: FilledButton.icon(
|
|
onPressed: busy ? null : _signInWithCredentials,
|
|
icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.login_rounded, fill: 1),
|
|
label: Text(t.addServer.signIn),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
];
|
|
}
|
|
|
|
Widget _buildInstanceCard(ThemeData theme, SeerrPublicSettings instance) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const AppIcon(Symbols.cloud_done_rounded, fill: 1),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(instance.instanceLabel, style: theme.textTheme.titleSmall),
|
|
Text(
|
|
_baseUrl,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.7)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
FocusableButton(
|
|
focusNode: _changeServerFocus,
|
|
useBackgroundFocus: true,
|
|
onPressed: busy ? null : _resetToUrlStep,
|
|
child: TextButton(onPressed: busy ? null : _resetToUrlStep, child: Text(t.addServer.change)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _resetToUrlStep() {
|
|
setState(() {
|
|
_instance = null;
|
|
_form = _CredentialForm.none;
|
|
_identifierController.clear();
|
|
_passwordController.clear();
|
|
});
|
|
}
|
|
}
|