UserProfileProvider kept a second cache of the active user's audio/subtitle defaults beside AccountPreferencesRepository, re-implemented the active profile/connection listener wiring and Plex Home token resolution, and for Plex Home profiles bypassed the shared cache entirely (fetching /user with its own client), so a write in the Account preferences screen never reached Home-profile playback. The controller now resolves the active account (the head of its sorted account list), loads it through the one repository on attach, on profile switch, and when the account's token changes, and exposes it as activePreferences; ensureActiveLoaded replaces initialize() for the startup gate. Playback, offline waiting, and logout storage clearing read the controller directly; UserProfileProvider, the per-backend user-profile DTOs, PlexAuthService.getUserProfile, and JellyfinClient.fetchUserProfile are deleted. The ranked defaultAudioLanguages/defaultSubtitleLanguages lists go with them: Plex Web never reads them, PMS applies them when it stamps `selected` on streams, and Plezy already trusts that flag ahead of the profile. They only refined a best-effort audio fallback for Plex items with no media info, which the primary language covers; on that path a second preferred language is no longer tried before the file's default track. Plex Home profiles now read /user/profile with the switched token through the same cache as local profiles.
580 lines
21 KiB
Dart
580 lines
21 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../connection/connection.dart';
|
|
import '../connection/connection_registry.dart';
|
|
import '../connection/plex_account_setup.dart';
|
|
import '../database/app_database.dart';
|
|
import '../mixins/controller_disposer_mixin.dart';
|
|
import '../profiles/active_profile_binder.dart';
|
|
import '../profiles/active_profile_provider.dart';
|
|
import '../profiles/plex_home_service.dart';
|
|
import '../profiles/profile.dart';
|
|
import '../profiles/profile_connection_registry.dart';
|
|
import '../profiles/profile_selection_policy.dart';
|
|
import '../services/plex_auth_service.dart';
|
|
import '../services/settings_service.dart';
|
|
import '../services/storage_service.dart';
|
|
import '../providers/account_preferences_controller.dart';
|
|
import '../i18n/strings.g.dart';
|
|
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 '../media/media_browser_dialect.dart';
|
|
import '../navigation/profile_session_screen.dart';
|
|
import '../utils/navigation_transitions.dart';
|
|
import '../widgets/backend_badge.dart';
|
|
import '../widgets/dialog_action_button.dart';
|
|
import 'auth/plex_pin_auth_flow.dart';
|
|
import 'profile/profile_switch_screen.dart';
|
|
import 'settings/add_jellyfin_screen.dart';
|
|
|
|
class AuthScreen extends StatefulWidget {
|
|
const AuthScreen({
|
|
super.key,
|
|
this.initialErrorMessage,
|
|
this.initializeServices = true,
|
|
this.databaseRecoveryRequired = false,
|
|
});
|
|
|
|
final String? initialErrorMessage;
|
|
final bool initializeServices;
|
|
final bool databaseRecoveryRequired;
|
|
@override
|
|
State<AuthScreen> createState() => _AuthScreenState();
|
|
}
|
|
|
|
class _AuthScreenState extends State<AuthScreen> {
|
|
bool _isAuthenticating = false;
|
|
String? _errorMessage;
|
|
// Reuse a one-shot service for the debug-token verify path; the Plex
|
|
// PIN/QR flow inside [PlexPinAuthFlow] owns its own service instance.
|
|
PlexAuthService? _verifyOnlyService;
|
|
Future<void>? _recoveryAcknowledgement;
|
|
bool _recoveryAcknowledged = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_errorMessage = widget.initialErrorMessage;
|
|
// Debug-token verification only — release builds must not hold an idle
|
|
// auth service (and its HTTP client) for a dialog that can't open.
|
|
if (kDebugMode && widget.initializeServices) unawaited(_initVerifyService());
|
|
}
|
|
|
|
Future<void> _initVerifyService() async {
|
|
final svc = await PlexAuthService.create();
|
|
if (!mounted) {
|
|
svc.dispose();
|
|
return;
|
|
}
|
|
setState(() => _verifyOnlyService = svc);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_verifyOnlyService?.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// Auto-select the active profile after sign-in *only* when there's a
|
|
/// single Plex Home user — there's no choice for the user to make. With
|
|
/// multiple Home users (the "real" Home case) we leave the active id
|
|
/// unset so [MainScreen] forces the picker before the binder runs,
|
|
/// avoiding a surprise PIN prompt on whichever user we'd otherwise
|
|
/// pre-select.
|
|
Future<void> _selectInitialProfile(
|
|
PlexHomeService plexHome,
|
|
ActiveProfileProvider activeProfiles,
|
|
PlexAccountConnection accountConn,
|
|
) async {
|
|
await activeProfiles.initialize();
|
|
final profile = initialPlexHomeProfileFromCache(plexHome, accountConn);
|
|
if (profile == null) {
|
|
await activeProfiles.clearActiveProfile();
|
|
return;
|
|
}
|
|
await activeProfiles.activate(profile);
|
|
}
|
|
|
|
Future<bool> _prepareDatabaseRecoveryForSignIn() async {
|
|
if (!widget.databaseRecoveryRequired || _recoveryAcknowledged) return true;
|
|
final acknowledgement = _recoveryAcknowledgement ??= context
|
|
.read<AppDatabase>()
|
|
.acknowledgeTvosDatabaseRecoveryRequired();
|
|
try {
|
|
await acknowledgement;
|
|
_recoveryAcknowledged = true;
|
|
if (mounted) setState(() => _errorMessage = null);
|
|
return mounted;
|
|
} catch (_) {
|
|
_recoveryAcknowledgement = null;
|
|
if (mounted) setState(() => _errorMessage = t.auth.localDataRecoveryRequired);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> _startPlexAfterRecovery(VoidCallback start) async {
|
|
if (await _prepareDatabaseRecoveryForSignIn()) start();
|
|
}
|
|
|
|
/// Persist the new Plex account into the connection pipeline, resolve the
|
|
/// initial active profile when possible, and navigate to the main screen.
|
|
/// The top-level [ActiveProfileBinder] picks up the active profile id and
|
|
/// connects servers via [MultiServerManager.refreshTokensForProfile].
|
|
Future<void> _connectToAllServersAndNavigate(String plexToken) async {
|
|
if (!await _prepareDatabaseRecoveryForSignIn()) return;
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_isAuthenticating = true;
|
|
_errorMessage = null;
|
|
});
|
|
|
|
final connectionRegistry = context.read<ConnectionRegistry>();
|
|
final profileConnections = context.read<ProfileConnectionRegistry>();
|
|
final plexHome = context.read<PlexHomeService>();
|
|
|
|
try {
|
|
final storage = await StorageService.getInstance();
|
|
final registration = await registerPlexAccountFromToken(
|
|
token: plexToken,
|
|
connections: connectionRegistry,
|
|
profileConnections: profileConnections,
|
|
storage: storage,
|
|
plexHome: plexHome,
|
|
);
|
|
final accountConnection = registration.connection;
|
|
|
|
if (accountConnection.servers.isEmpty) {
|
|
// Nothing to roll back — the registered account row is exactly what
|
|
// a retry will upsert over; wiping global credentials here would
|
|
// also nuke the device identity and every server-endpoint cache.
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isAuthenticating = false;
|
|
_errorMessage = t.serverSelection.noServersFoundForAccount(
|
|
username: registration.username,
|
|
email: registration.email,
|
|
);
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!registration.homeUsersFetched && plexHome.current[accountConnection.id] == null) {
|
|
// A failed home-user fetch is NOT "multiple users, let them pick":
|
|
// with no home users and no locals there is no profile to select,
|
|
// and navigating lands in a dead session. Surface a retry instead.
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isAuthenticating = false;
|
|
_errorMessage = t.profiles.failedToLoadHomeUsers;
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!mounted) return;
|
|
final activeProfiles = context.read<ActiveProfileProvider>();
|
|
await _selectInitialProfile(plexHome, activeProfiles, accountConnection);
|
|
|
|
if (!mounted) return;
|
|
|
|
// Start the binder before the picker/MainScreen, mirroring the
|
|
// cold-start SetupScreen ordering. On a fresh install SetupScreen
|
|
// routes here without ever starting it, so without this the profile
|
|
// activated above is bound only by MainScreen's post-frame start() —
|
|
// Discover renders a "No servers available" flash in the gap, and the
|
|
// picker's awaitBindingSettle resolves before anything is bound.
|
|
context.read<ActiveProfileBinder>().start();
|
|
|
|
final settings = await SettingsService.getInstance();
|
|
if (!mounted) return;
|
|
|
|
final promptHandled = shouldPromptForInitialProfileSelection(
|
|
activeProfile: activeProfiles.active,
|
|
hasProfiles: activeProfiles.profiles.isNotEmpty,
|
|
accountHasHomeUsers: plexHome.current[accountConnection.id]?.isNotEmpty == true,
|
|
requireProfileSelectionOnOpen: activeProfiles.requiresSelectionOnOpen(settings),
|
|
);
|
|
if (promptHandled) {
|
|
final selected = await Navigator.of(
|
|
context,
|
|
).push<bool>(MaterialPageRoute(builder: (_) => const ProfileSwitchScreen(requireSelection: true)));
|
|
if (!mounted) return;
|
|
if (selected != true || activeProfiles.active == null) {
|
|
setState(() => _isAuthenticating = false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
await context.read<AccountPreferencesController>().ensureActiveLoaded();
|
|
|
|
if (!mounted) return;
|
|
unawaited(
|
|
Navigator.pushReplacement(context, fadeRoute(ProfileSessionScreen(initialPromptHandled: promptHandled))),
|
|
);
|
|
} catch (e) {
|
|
appLogger.e('Failed to connect to servers', error: e);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isAuthenticating = false;
|
|
_errorMessage = t.serverSelection.failedToLoadServers(error: e);
|
|
});
|
|
}
|
|
}
|
|
|
|
void _handleDebugTap() {
|
|
if (!kDebugMode) return;
|
|
_showDebugTokenDialog();
|
|
}
|
|
|
|
Future<void> _connectToMediaBrowser(MediaBrowserDialect dialect) async {
|
|
if (!await _prepareDatabaseRecoveryForSignIn()) return;
|
|
if (!mounted) return;
|
|
final added = await Navigator.push<bool>(
|
|
context,
|
|
MaterialPageRoute(builder: (_) => AddJellyfinScreen(dialect: dialect)),
|
|
);
|
|
if (!mounted || added != true) return;
|
|
// The connection persisted and the manager registered the client; move
|
|
// straight to the main screen. [MainScreen] reads the active client
|
|
// from the server provider, so no client argument is needed here.
|
|
unawaited(Navigator.pushReplacement(context, fadeRoute(const ProfileSessionScreen())));
|
|
}
|
|
|
|
void _showDebugTokenDialog() {
|
|
showDialog<void>(
|
|
context: context,
|
|
builder: (BuildContext context) {
|
|
return _DebugTokenDialog(verifyService: _verifyOnlyService, onTokenAccepted: _connectToAllServersAndNavigate);
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Use two-column layout on desktop, single column on mobile
|
|
final isDesktop = MediaQuery.sizeOf(context).width > 700;
|
|
|
|
return Focus(
|
|
canRequestFocus: false,
|
|
onKeyEvent: (_, event) => handleBackKeyNavigation(context, event),
|
|
child: Scaffold(
|
|
body: Center(
|
|
child: Container(
|
|
constraints: BoxConstraints(maxWidth: isDesktop ? 800 : 400),
|
|
padding: .fromLTRB(24, 24, 24, 24 + MediaQuery.paddingOf(context).bottom),
|
|
child: isDesktop
|
|
? Row(
|
|
crossAxisAlignment: .center,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisAlignment: .center,
|
|
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: .bold),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 48),
|
|
Expanded(
|
|
child: Center(
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: .min,
|
|
crossAxisAlignment: .stretch,
|
|
children: [_buildAuthBody()],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
)
|
|
: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: .min,
|
|
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: .bold),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 48),
|
|
_buildAuthBody(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAuthBody() {
|
|
if (_isAuthenticating) {
|
|
return Column(
|
|
mainAxisSize: .min,
|
|
children: [
|
|
const Center(child: CircularProgressIndicator()),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
t.auth.waitingForAuth,
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
return PlexPinAuthFlow(
|
|
onTokenReceived: _connectToAllServersAndNavigate,
|
|
autoStartQrOnTV: false,
|
|
initializeService: widget.initializeServices,
|
|
initialButtonsBuilder: _buildInitialButtons,
|
|
);
|
|
}
|
|
|
|
Widget _buildInitialButtons(BuildContext context, VoidCallback startBrowser, VoidCallback startQr, bool busy) {
|
|
final isTV = PlatformDetector.isTV();
|
|
final isAppleTV = PlatformDetector.isAppleTV();
|
|
void startBrowserAfterRecovery() => unawaited(_startPlexAfterRecovery(startBrowser));
|
|
void startQrAfterRecovery() => unawaited(_startPlexAfterRecovery(startQr));
|
|
const jellyfinDialect = MediaBrowserDialect.jellyfin;
|
|
const embyDialect = MediaBrowserDialect.emby;
|
|
void connectToJellyfin() => unawaited(_connectToMediaBrowser(jellyfinDialect));
|
|
void connectToEmby() => unawaited(_connectToMediaBrowser(embyDialect));
|
|
return Column(
|
|
mainAxisSize: .min,
|
|
crossAxisAlignment: .stretch,
|
|
children: [
|
|
if (isTV) ...[
|
|
FocusableButton(
|
|
autofocus: true,
|
|
onPressed: busy ? null : startQrAfterRecovery,
|
|
useBackgroundFocus: true,
|
|
child: ElevatedButton(
|
|
onPressed: busy ? null : startQrAfterRecovery,
|
|
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
|
child: Row(
|
|
mainAxisAlignment: .center,
|
|
mainAxisSize: .min,
|
|
children: [
|
|
const BackendBadge(backend: MediaBackend.plex, size: 18),
|
|
const SizedBox(width: 8),
|
|
Text(t.auth.signInWithPlex),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (!isAppleTV) ...[
|
|
const SizedBox(height: 12),
|
|
FocusableButton(
|
|
onPressed: busy ? null : startBrowserAfterRecovery,
|
|
child: OutlinedButton(
|
|
onPressed: busy ? null : startBrowserAfterRecovery,
|
|
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
|
child: Text(t.auth.useBrowser),
|
|
),
|
|
),
|
|
],
|
|
] else ...[
|
|
FocusableButton(
|
|
onPressed: busy ? null : startBrowserAfterRecovery,
|
|
useBackgroundFocus: true,
|
|
child: ElevatedButton.icon(
|
|
onPressed: busy ? null : startBrowserAfterRecovery,
|
|
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),
|
|
FocusableButton(
|
|
onPressed: busy ? null : startQrAfterRecovery,
|
|
child: OutlinedButton(
|
|
onPressed: busy ? null : startQrAfterRecovery,
|
|
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
|
child: Text(t.auth.showQRCode),
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 24),
|
|
Row(
|
|
children: [
|
|
Expanded(child: Divider(color: Theme.of(context).colorScheme.outlineVariant)),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
child: Text(
|
|
t.auth.or,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6)),
|
|
),
|
|
),
|
|
Expanded(child: Divider(color: Theme.of(context).colorScheme.outlineVariant)),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
FocusableButton(
|
|
onPressed: 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.connectToMediaBrowser(product: jellyfinDialect.productName)),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
FocusableButton(
|
|
onPressed: connectToEmby,
|
|
child: OutlinedButton.icon(
|
|
onPressed: connectToEmby,
|
|
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
|
icon: const BackendBadge(backend: MediaBackend.emby, size: 18),
|
|
label: Text(t.auth.connectToMediaBrowser(product: embyDialect.productName)),
|
|
),
|
|
),
|
|
if (kDebugMode) ...[
|
|
const SizedBox(height: 12),
|
|
FocusableButton(
|
|
onPressed: _handleDebugTap,
|
|
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)),
|
|
),
|
|
),
|
|
],
|
|
if (_errorMessage != null) ...[
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
_errorMessage!,
|
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
@visibleForTesting
|
|
Profile? initialPlexHomeProfileFromCache(PlexHomeService plexHome, PlexAccountConnection accountConn) {
|
|
final users = plexHome.current[accountConn.id];
|
|
if (users == null || users.length != 1) return null;
|
|
return Profile.virtualPlexHome(connectionId: accountConn.id, homeUser: users.single);
|
|
}
|
|
|
|
@visibleForTesting
|
|
bool shouldPromptForInitialProfileSelection({
|
|
required Profile? activeProfile,
|
|
required bool hasProfiles,
|
|
required bool accountHasHomeUsers,
|
|
required bool requireProfileSelectionOnOpen,
|
|
}) {
|
|
return requireProfileSelectionOnOpen || (activeProfile == null && (hasProfiles || accountHasHomeUsers));
|
|
}
|
|
|
|
/// Stateful so the [TextEditingController] is disposed when the dialog
|
|
/// closes — the previous inline `showDialog` builder created the
|
|
/// controller in a closure and leaked it on every dismissal.
|
|
class _DebugTokenDialog extends StatefulWidget {
|
|
final PlexAuthService? verifyService;
|
|
final Future<void> Function(String token) onTokenAccepted;
|
|
|
|
const _DebugTokenDialog({required this.verifyService, required this.onTokenAccepted});
|
|
|
|
@override
|
|
State<_DebugTokenDialog> createState() => _DebugTokenDialogState();
|
|
}
|
|
|
|
class _DebugTokenDialogState extends State<_DebugTokenDialog> with ControllerDisposerMixin {
|
|
late final TextEditingController _tokenController = createTextEditingController();
|
|
String? _errorMessage;
|
|
bool _busy = false;
|
|
|
|
Future<void> _submit() async {
|
|
final token = _tokenController.text.trim();
|
|
if (token.isEmpty) {
|
|
setState(() => _errorMessage = t.errors.pleaseEnterToken);
|
|
return;
|
|
}
|
|
final svc = widget.verifyService;
|
|
if (svc == null) {
|
|
setState(() => _errorMessage = 'Auth service not ready');
|
|
return;
|
|
}
|
|
final navigator = Navigator.of(context);
|
|
setState(() {
|
|
_errorMessage = null;
|
|
_busy = true;
|
|
});
|
|
try {
|
|
final isValid = await svc.verifyToken(token);
|
|
if (!mounted) return;
|
|
if (!isValid) {
|
|
setState(() {
|
|
_errorMessage = t.errors.invalidToken;
|
|
_busy = false;
|
|
});
|
|
return;
|
|
}
|
|
navigator.pop();
|
|
await widget.onTokenAccepted(token);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_errorMessage = t.errors.failedToVerifyToken(error: e);
|
|
_busy = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: const Text('Debug: Enter Plex Token'),
|
|
content: Column(
|
|
mainAxisSize: .min,
|
|
children: [
|
|
FocusableTextFormField(
|
|
controller: _tokenController,
|
|
decoration: InputDecoration(
|
|
labelText: 'Plex Auth Token',
|
|
hintText: 'Enter your Plex.tv token',
|
|
errorText: _errorMessage,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
obscureText: true,
|
|
maxLines: 1,
|
|
textInputAction: TextInputAction.done,
|
|
onFieldSubmitted: (_) => _busy ? null : _submit(),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
DialogActionButton(onPressed: _busy ? null : () => Navigator.of(context).pop(), label: t.common.cancel),
|
|
DialogActionButton(onPressed: _busy ? null : _submit, label: t.auth.authenticate, isPrimary: true),
|
|
],
|
|
);
|
|
}
|
|
}
|