import 'dart:developer'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:halkyon_app/l10n/halkyon_app_localizations.dart'; import 'package:halkyon_app/src/app/app_scope.dart'; import 'package:halkyon_app/src/auth/bloc/session_bloc.dart'; import 'package:halkyon_app/src/instance/instance_scope.dart'; import 'package:halkyon_app/src/instance/widget/account_switcher_sheet.dart'; import 'package:halkyon_app/src/password/model/account_security.dart'; import 'package:halkyon_app/src/password/model/password_failed.dart'; import 'package:halkyon_app/src/password/widget/delete_account_sheet.dart'; import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:material_ui/material_ui.dart'; /// The account in use, as an app's settings show it (decided 2026-10-16): /// whose it is, anonymous or signed in, on which instance; switching to the /// account on another instance, where the device keeps accounts on several /// ([InstanceScope]); signing out, which of an anonymous account deletes it /// and so asks first; deleting it, after asking. What only the app can say, /// it gives: what an anonymous account keeps on the device, and what signing /// out of one and deleting the account take with them. class const AccountTiles({ /// Under an anonymous account: what it keeps on this device ("Progress is /// saved on this device. Sign in to keep it."); none where null. final String? anonymousNote, /// What signing out of an anonymous account takes with it, as the /// question before asks. required final String signOutAnonymousMessage, /// What deleting the account takes with it, as the question before asks. required final String deleteMessage, super.key, }) extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = HalkyonAppLocalizations.of(context); final theme = Theme.of(context); final identified = switch (context.watch().state) { SessionStateActive(:final person) => person.isIdentified, _ => false, }; final instances = InstanceScope.maybeOf(context); final instance = instances?.choice.current?.name ?? AppScope.of>(context).serverName; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ListTile( leading: Icon( identified ? LucideIcons.userCheck : LucideIcons.userRound, ), title: Text( identified ? l10n.accountSignedInOn(instance) : l10n.accountAnonymousOn(instance), ), subtitle: switch (anonymousNote) { final note? when !identified => Text(note), _ => null, }, ), // The accounts on this device, one per instance. if (instances != null) ListTile( leading: const Icon(LucideIcons.users), title: Text(l10n.accountSwitch), subtitle: Text(instances.choice.current?.name ?? ''), onTap: () => showAccountSwitcher(context), ), ListTile( leading: const Icon(LucideIcons.logOut), title: Text(l10n.accountSignOut), onTap: () => _signOut(context), ), ListTile( leading: Icon(LucideIcons.trash2, color: theme.colorScheme.error), title: Text( l10n.accountDelete, style: TextStyle(color: theme.colorScheme.error), ), onTap: () => _delete(context), ), ], ); } /// Signs out; an anonymous account can't be signed into again, so signing /// out of one deletes it, and asks first. The app then asks how to start. Future _signOut(BuildContext context) async { final l10n = HalkyonAppLocalizations.of(context); final session = context.read(); final identified = switch (session.state) { SessionStateActive(:final person) => person.isIdentified, _ => false, }; if (!identified && !await confirmDestructiveAction( context, title: l10n.accountSignOutTitle, message: signOutAnonymousMessage, confirmLabel: l10n.accountSignOut, )) { return; } session.add(const SessionEventSignOutRequested()); } /// Deletes the account with everything in it, after asking; an account /// with a password only with it and a code (ADR 0017), which a sheet asks /// for. Future _delete(BuildContext context) async { final l10n = HalkyonAppLocalizations.of(context); final session = context.read(); final passwords = AppScope.of(context).passwords; final identified = switch (session.state) { SessionStateActive(:final person) => person.isIdentified, _ => false, }; if (!await confirmDestructiveAction( context, title: l10n.accountDeleteTitle, message: deleteMessage, confirmLabel: l10n.accountDelete, )) { return; } final AccountSecurity? security; try { security = identified ? await passwords.security() : null; } on Object catch (error) { if (error is PasswordFailed && error.problem != PasswordProblem.failed) { // The instance refused to say (no passwords offered there): the // session's own deletion, which the instance checks. session.add(const SessionEventDeleteRequested()); return; } // Out of reach, or anything else: said so, nothing deleted (the short // reading: a deletion by the session alone, refused for an account // with a password, showed a spinner, then nothing). if (error is! PasswordFailed) { log('Reading the account failed: ${error.runtimeType}', name: 'Auth'); } if (context.mounted) { ScaffoldMessenger.maybeOf( context, )?.showSnackBar(SnackBar(content: Text(l10n.passwordFailed))); } return; } if (security == null || !security.hasPassword) { session.add(const SessionEventDeleteRequested()); } else if (context.mounted) { // Without a second factor that works, a code by mail confirms it. await showDeleteAccountWithPassword( context, byMail: !security.secondFactorWorks, ); } } }