161 lines
6.1 KiB
Dart
161 lines
6.1 KiB
Dart
import 'package:flutter/material.dart';
|
|
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/focused_scroll_scaffold.dart';
|
|
import '../settings/add_connection_screen.dart';
|
|
import 'pin_entry_dialog.dart';
|
|
import 'pin_status_row.dart';
|
|
import 'profile_name_field.dart';
|
|
|
|
/// Create a local "Plezy" profile — name + optional 4-digit PIN.
|
|
///
|
|
/// On save, routes into [AddConnectionScreen] so the user can either add
|
|
/// a brand-new Plex/Jellyfin connection to this profile or borrow one from
|
|
/// an existing profile. Profiles with zero connections are stored but
|
|
/// blocked from activation.
|
|
class AddLocalProfileScreen extends StatefulWidget {
|
|
const AddLocalProfileScreen({super.key});
|
|
|
|
@override
|
|
State<AddLocalProfileScreen> createState() => _AddLocalProfileScreenState();
|
|
}
|
|
|
|
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,
|
|
onMismatch: (ctx) => showErrorSnackBar(ctx, t.profiles.pinsDontMatch),
|
|
);
|
|
if (pin == null || !mounted) return;
|
|
setState(() => _pinHash = computePinHash(pin));
|
|
}
|
|
|
|
void _clearPin() => setState(() => _pinHash = null);
|
|
|
|
Future<void> _saveAndContinue() async {
|
|
if (_saving) return;
|
|
final name = _nameController.text.trim();
|
|
if (name.isEmpty) return;
|
|
setState(() => _saving = true);
|
|
|
|
try {
|
|
final registry = context.read<ProfileRegistry>();
|
|
final profile = Profile.local(
|
|
id: 'local-${const Uuid().v4()}',
|
|
displayName: name,
|
|
pinHash: _pinHash,
|
|
sortOrder: DateTime.now().millisecondsSinceEpoch,
|
|
createdAt: DateTime.now(),
|
|
);
|
|
await registry.upsert(profile);
|
|
|
|
if (!mounted) return;
|
|
// Drop the user into the connection picker so they end up with at least
|
|
// one connection. The picker offers both new sign-ins and borrowing from
|
|
// existing profiles — empty borrow lists no longer trap the user.
|
|
final navigator = Navigator.of(context);
|
|
await navigator.push(MaterialPageRoute(builder: (_) => AddConnectionScreen(targetProfile: profile)));
|
|
if (!mounted) return;
|
|
navigator.pop(true);
|
|
} finally {
|
|
// A failed upsert (or returning from the connection picker without
|
|
// popping) must re-enable the button instead of wedging it.
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
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,
|
|
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),
|
|
FocusableButton(
|
|
focusNode: _continueFocus,
|
|
useBackgroundFocus: true,
|
|
onPressed: _saving || _nameController.text.trim().isEmpty ? null : _saveAndContinue,
|
|
onNavigateUp: () => (_pinHash == null ? _setPinFocus : _nameFocus).requestFocus(),
|
|
child: FilledButton(
|
|
onPressed: _saving || _nameController.text.trim().isEmpty ? null : _saveAndContinue,
|
|
child: Text(t.profiles.continueButton),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
FocusableButton(
|
|
focusNode: _cancelFocus,
|
|
useBackgroundFocus: true,
|
|
onPressed: _saving ? null : () => Navigator.of(context).pop(),
|
|
child: TextButton(
|
|
onPressed: _saving ? null : () => Navigator.of(context).pop(),
|
|
child: Text(t.common.cancel),
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|