import 'dart:async'; import 'package:copy_with/copy_with.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/bloc_util.dart'; import 'package:nc_photos/controller/account_controller.dart'; import 'package:nc_photos/controller/account_pref_controller.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/person.dart'; import 'package:nc_photos/entity/pref.dart'; import 'package:nc_photos/exception_event.dart'; import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/help_utils.dart' as help_util; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/url_launcher_util.dart'; import 'package:nc_photos/widget/home.dart'; import 'package:nc_photos/widget/page_visibility_mixin.dart'; import 'package:nc_photos/widget/root_picker.dart'; import 'package:nc_photos/widget/settings/settings_list_caption.dart'; import 'package:nc_photos/widget/share_folder_picker.dart'; import 'package:nc_photos/widget/simple_input_dialog.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_ui/np_ui.dart'; import 'package:to_string/to_string.dart'; part 'account/bloc.dart'; part 'account/state_event.dart'; part 'account_settings.g.dart'; // typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; typedef _BlocListener = BlocListener<_Bloc, _State>; typedef _BlocSelector = BlocSelector<_Bloc, _State, T>; enum AccountSettingsOption { personProvider, } class AccountSettingsArguments { const AccountSettingsArguments({ this.highlight, }); final AccountSettingsOption? highlight; } class AccountSettings extends StatelessWidget { static const routeName = "/settings/account"; static Route buildRoute(AccountSettingsArguments? args) => MaterialPageRoute( builder: (_) => AccountSettings.fromArgs(args), ); const AccountSettings({ super.key, this.highlight, }); AccountSettings.fromArgs(AccountSettingsArguments? args, {Key? key}) : this( key: key, highlight: args?.highlight, ); @override Widget build(BuildContext context) { final accountController = context.read(); return BlocProvider( create: (_) => _Bloc( container: KiwiContainer().resolve(), account: accountController.account, accountPrefController: accountController.accountPrefController, highlight: highlight, ), child: const _WrappedAccountSettings(), ); } final AccountSettingsOption? highlight; } class _WrappedAccountSettings extends StatefulWidget { const _WrappedAccountSettings(); @override State createState() => _WrappedAccountSettingsState(); } @npLog class _WrappedAccountSettingsState extends State<_WrappedAccountSettings> with RouteAware, PageVisibilityMixin, TickerProviderStateMixin { @override void initState() { super.initState(); _accountController = context.read(); WidgetsBinding.instance.addPostFrameCallback((_) { _animationController.repeat(reverse: true); }); } @override void dispose() { if (_bloc.state.shouldResync && _bloc.state.personProvider != PersonProvider.none) { _log.fine("[dispose] Requesting to resync account"); _accountController.syncController.requestResync( account: _bloc.state.account, filesController: context.read(), personsController: context.read(), personProvider: _bloc.state.personProvider, ); } _animationController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( body: MultiBlocListener( listeners: [ _BlocListener( listenWhen: (previous, current) => previous.error != current.error, listener: (context, state) { if (state.error != null && isPageVisible()) { final String errorMsg; if (state.error is _AccountConflictError) { errorMsg = L10n.global().editAccountConflictFailureNotification; } else if (state.error is _WritePrefError) { errorMsg = L10n.global().writePreferenceFailureNotification; } else { errorMsg = exception_util.toUserString(state.error!.error); } SnackBarManager().showSnackBar(SnackBar( content: Text(errorMsg), duration: k.snackBarDurationNormal, )); } }, ), ], child: WillPopScope( onWillPop: () async => !_bloc.state.shouldReload, child: CustomScrollView( slivers: [ SliverAppBar( pinned: true, title: Text(L10n.global().settingsAccountTitle), leading: _BlocSelector( selector: (state) => state.shouldReload, builder: (_, state) => state ? const _DoneButton() : const BackButton(), ), ), SliverList( delegate: SliverChildListDelegate( [ _BlocSelector( selector: (state) => state.label, builder: (context, state) => ListTile( title: Text(L10n.global().settingsAccountLabelTitle), subtitle: Text(state ?? L10n.global().settingsAccountLabelDescription), onTap: () => _onLabelPressed(context), ), ), _BlocSelector( selector: (state) => state.account, builder: (context, state) => ListTile( title: Text(L10n.global().settingsIncludedFoldersTitle), subtitle: Text(state.roots.map((e) => "/$e").join("; ")), onTap: () => _onIncludedFoldersPressed(context), ), ), _BlocSelector( selector: (state) => state.shareFolder, builder: (context, state) => ListTile( title: Text(L10n.global().settingsShareFolderTitle), subtitle: Text("/$state"), onTap: () => _onShareFolderPressed(context), ), ), SettingsListCaption( label: L10n.global().settingsServerAppSectionTitle, ), _BlocSelector( selector: (state) => state.personProvider, builder: (context, state) { if (_bloc.highlight == AccountSettingsOption.personProvider) { return AnimatedBuilder( animation: _highlightAnimation, builder: (context, child) => ListTile( title: Text( L10n.global().settingsPersonProviderTitle), subtitle: Text(state.toUserString()), onTap: () => _onPersonProviderPressed(context), tileColor: _highlightAnimation.value, ), ); } else { return ListTile( title: Text(L10n.global().settingsPersonProviderTitle), subtitle: Text(state.toUserString()), onTap: () => _onPersonProviderPressed(context), ); } }, ), ], ), ), ], ), ), ), ); } Future _onLabelPressed(BuildContext context) async { final result = await showDialog( context: context, builder: (context) => SimpleInputDialog( titleText: L10n.global().settingsAccountLabelTitle, buttonText: MaterialLocalizations.of(context).okButtonLabel, initialText: _bloc.state.label ?? "", ), ); if (!context.mounted || result == null) { return; } _bloc.add(_SetLabel(result.isEmpty ? null : result)); } Future _onIncludedFoldersPressed(BuildContext context) async { final result = await Navigator.of(context).pushNamed( RootPicker.routeName, arguments: RootPickerArguments(_bloc.state.account), ); if (result == null) { return; } if (result == _bloc.state.account) { // no changes, do nothing _log.fine("[_onIncludedFoldersPressed] No changes"); return; } _bloc.add(_SetAccount(result)); } Future _onShareFolderPressed(BuildContext context) async { final result = await showDialog( context: context, builder: (_) => _ShareFolderDialog( account: _bloc.state.account, initialValue: _bloc.state.shareFolder, ), ); if (!context.mounted || result == null) { return; } _bloc.add(_SetShareFolder(result)); } Future _onPersonProviderPressed(BuildContext context) async { final result = await showDialog( context: context, builder: (_) => _PersonProviderDialog( initialValue: _bloc.state.personProvider, ), ); if (!context.mounted || result == null) { return; } _bloc.add(_SetPersonProvider(result)); } late final _bloc = context.read<_Bloc>(); late final AccountController _accountController; late final _animationController = AnimationController( vsync: this, duration: k.settingsHighlightDuration, ); late final _highlightAnimation = ColorTween( end: Theme.of(context).colorScheme.primaryContainer, ).animate(_animationController); } class _DoneButton extends StatelessWidget { const _DoneButton(); @override Widget build(BuildContext context) { return IconButton( icon: const Icon(Icons.check), tooltip: L10n.global().doneButtonTooltip, onPressed: () { final newAccount = context.read<_Bloc>().state.account; context.read().setCurrentAccount(newAccount); Navigator.of(context).pushNamedAndRemoveUntil( Home.routeName, (_) => false, arguments: HomeArguments(newAccount), ); }, ); } } class _ShareFolderDialog extends StatefulWidget { const _ShareFolderDialog({ required this.account, required this.initialValue, }); @override State createState() => _ShareFolderDialogState(); final Account account; final String initialValue; } class _ShareFolderDialogState extends State<_ShareFolderDialog> { @override Widget build(BuildContext context) { return AlertDialog( title: Text(L10n.global().settingsShareFolderDialogTitle), content: Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(L10n.global().settingsShareFolderDialogDescription), const SizedBox(height: 8), InkWell( onTap: _onTextFieldPressed, child: TextFormField( enabled: false, controller: _controller, ), ), ], ), ), actions: [ TextButton( onPressed: _onOkPressed, child: Text(MaterialLocalizations.of(context).okButtonLabel), ), ], ); } Future _onTextFieldPressed() async { final pick = await Navigator.of(context).pushNamed( ShareFolderPicker.routeName, arguments: ShareFolderPickerArguments(widget.account, _path), ); if (pick != null) { _path = pick; _controller.text = "/$pick"; } } void _onOkPressed() { Navigator.of(context).pop(_path); } final _formKey = GlobalKey(); late final _controller = TextEditingController(text: "/${widget.initialValue}"); late String _path = widget.initialValue; } @npLog class _PersonProviderDialog extends StatelessWidget { const _PersonProviderDialog({ required this.initialValue, }); @override Widget build(BuildContext context) { return FancyOptionPicker( title: Row( children: [ Expanded( child: Text(L10n.global().settingsPersonProviderTitle), ), IconButton( onPressed: () { launch(help_util.peopleUrl); }, tooltip: L10n.global().helpTooltip, icon: const Icon(Icons.help_outline), ), ], ), items: PersonProvider.values .map((provider) => FancyOptionPickerItem( label: provider.toUserString(), isSelected: provider == initialValue, onSelect: () { _log.info("[build] Set provider: ${provider.toUserString()}"); Navigator.of(context).pop(provider); }, )) .toList(), ); } final PersonProvider initialValue; } extension on PersonProvider { String toUserString() { switch (this) { case PersonProvider.none: return "n/a"; case PersonProvider.faceRecognition: return "Face Recognition"; case PersonProvider.recognize: return "Recognize"; } } }