From 7f26196e85b76037792e05a4eca089f45a2298a6 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Mon, 16 Jun 2025 00:53:26 +0800 Subject: [PATCH] :art: Split up account settings page --- assets/i18n/en-US.json | 2 +- lib/models/auth.dart | 18 + lib/models/auth.freezed.dart | 163 +++++ lib/models/auth.g.dart | 30 + lib/screens/account.dart | 20 +- lib/screens/account/me/settings.dart | 606 +----------------- .../account/me/settings_auth_factors.dart | 342 ++++++++++ lib/screens/account/me/settings_contacts.dart | 281 ++++++++ 8 files changed, 851 insertions(+), 611 deletions(-) create mode 100644 lib/screens/account/me/settings_auth_factors.dart create mode 100644 lib/screens/account/me/settings_contacts.dart diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 5a1abfc..5d06851 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -28,7 +28,7 @@ "fieldCannotBeEmpty": "This field cannot be empty.", "fieldEmailAddressMustBeValid": "The email address must be valid.", "logout": "Logout", - "updateYourProfile": "Edit Profile", + "updateYourProfile": "Profile Settings", "accountBasicInfo": "Basic Info", "accountProfile": "Your Profile", "saveChanges": "Save Changes", diff --git a/lib/models/auth.dart b/lib/models/auth.dart index 2874c06..77281a3 100644 --- a/lib/models/auth.dart +++ b/lib/models/auth.dart @@ -91,3 +91,21 @@ sealed class SnAuthDevice with _$SnAuthDevice { factory SnAuthDevice.fromJson(Map json) => _$SnAuthDeviceFromJson(json); } + +@freezed +sealed class SnAccountConnection with _$SnAccountConnection { + const factory SnAccountConnection({ + required String id, + required String accountId, + required String provider, + required String providedIdentifier, + @Default({}) Map meta, + required DateTime lastUsedAt, + required DateTime createdAt, + required DateTime updatedAt, + required DateTime? deletedAt, + }) = _SnAccountConnection; + + factory SnAccountConnection.fromJson(Map json) => + _$SnAccountConnectionFromJson(json); +} diff --git a/lib/models/auth.freezed.dart b/lib/models/auth.freezed.dart index 8f6babe..0fdeee2 100644 --- a/lib/models/auth.freezed.dart +++ b/lib/models/auth.freezed.dart @@ -847,6 +847,169 @@ as bool, } +} + + +/// @nodoc +mixin _$SnAccountConnection { + + String get id; String get accountId; String get provider; String get providedIdentifier; Map get meta; DateTime get lastUsedAt; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; +/// Create a copy of SnAccountConnection +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SnAccountConnectionCopyWith get copyWith => _$SnAccountConnectionCopyWithImpl(this as SnAccountConnection, _$identity); + + /// Serializes this SnAccountConnection to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAccountConnection&&(identical(other.id, id) || other.id == id)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.provider, provider) || other.provider == provider)&&(identical(other.providedIdentifier, providedIdentifier) || other.providedIdentifier == providedIdentifier)&&const DeepCollectionEquality().equals(other.meta, meta)&&(identical(other.lastUsedAt, lastUsedAt) || other.lastUsedAt == lastUsedAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,accountId,provider,providedIdentifier,const DeepCollectionEquality().hash(meta),lastUsedAt,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnAccountConnection(id: $id, accountId: $accountId, provider: $provider, providedIdentifier: $providedIdentifier, meta: $meta, lastUsedAt: $lastUsedAt, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $SnAccountConnectionCopyWith<$Res> { + factory $SnAccountConnectionCopyWith(SnAccountConnection value, $Res Function(SnAccountConnection) _then) = _$SnAccountConnectionCopyWithImpl; +@useResult +$Res call({ + String id, String accountId, String provider, String providedIdentifier, Map meta, DateTime lastUsedAt, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + + + +} +/// @nodoc +class _$SnAccountConnectionCopyWithImpl<$Res> + implements $SnAccountConnectionCopyWith<$Res> { + _$SnAccountConnectionCopyWithImpl(this._self, this._then); + + final SnAccountConnection _self; + final $Res Function(SnAccountConnection) _then; + +/// Create a copy of SnAccountConnection +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? accountId = null,Object? provider = null,Object? providedIdentifier = null,Object? meta = null,Object? lastUsedAt = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable +as String,provider: null == provider ? _self.provider : provider // ignore: cast_nullable_to_non_nullable +as String,providedIdentifier: null == providedIdentifier ? _self.providedIdentifier : providedIdentifier // ignore: cast_nullable_to_non_nullable +as String,meta: null == meta ? _self.meta : meta // ignore: cast_nullable_to_non_nullable +as Map,lastUsedAt: null == lastUsedAt ? _self.lastUsedAt : lastUsedAt // ignore: cast_nullable_to_non_nullable +as DateTime,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable +as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +} + + +/// @nodoc +@JsonSerializable() + +class _SnAccountConnection implements SnAccountConnection { + const _SnAccountConnection({required this.id, required this.accountId, required this.provider, required this.providedIdentifier, final Map meta = const {}, required this.lastUsedAt, required this.createdAt, required this.updatedAt, required this.deletedAt}): _meta = meta; + factory _SnAccountConnection.fromJson(Map json) => _$SnAccountConnectionFromJson(json); + +@override final String id; +@override final String accountId; +@override final String provider; +@override final String providedIdentifier; + final Map _meta; +@override@JsonKey() Map get meta { + if (_meta is EqualUnmodifiableMapView) return _meta; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_meta); +} + +@override final DateTime lastUsedAt; +@override final DateTime createdAt; +@override final DateTime updatedAt; +@override final DateTime? deletedAt; + +/// Create a copy of SnAccountConnection +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SnAccountConnectionCopyWith<_SnAccountConnection> get copyWith => __$SnAccountConnectionCopyWithImpl<_SnAccountConnection>(this, _$identity); + +@override +Map toJson() { + return _$SnAccountConnectionToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAccountConnection&&(identical(other.id, id) || other.id == id)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.provider, provider) || other.provider == provider)&&(identical(other.providedIdentifier, providedIdentifier) || other.providedIdentifier == providedIdentifier)&&const DeepCollectionEquality().equals(other._meta, _meta)&&(identical(other.lastUsedAt, lastUsedAt) || other.lastUsedAt == lastUsedAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,accountId,provider,providedIdentifier,const DeepCollectionEquality().hash(_meta),lastUsedAt,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnAccountConnection(id: $id, accountId: $accountId, provider: $provider, providedIdentifier: $providedIdentifier, meta: $meta, lastUsedAt: $lastUsedAt, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$SnAccountConnectionCopyWith<$Res> implements $SnAccountConnectionCopyWith<$Res> { + factory _$SnAccountConnectionCopyWith(_SnAccountConnection value, $Res Function(_SnAccountConnection) _then) = __$SnAccountConnectionCopyWithImpl; +@override @useResult +$Res call({ + String id, String accountId, String provider, String providedIdentifier, Map meta, DateTime lastUsedAt, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + + + +} +/// @nodoc +class __$SnAccountConnectionCopyWithImpl<$Res> + implements _$SnAccountConnectionCopyWith<$Res> { + __$SnAccountConnectionCopyWithImpl(this._self, this._then); + + final _SnAccountConnection _self; + final $Res Function(_SnAccountConnection) _then; + +/// Create a copy of SnAccountConnection +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? accountId = null,Object? provider = null,Object? providedIdentifier = null,Object? meta = null,Object? lastUsedAt = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_SnAccountConnection( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable +as String,provider: null == provider ? _self.provider : provider // ignore: cast_nullable_to_non_nullable +as String,providedIdentifier: null == providedIdentifier ? _self.providedIdentifier : providedIdentifier // ignore: cast_nullable_to_non_nullable +as String,meta: null == meta ? _self._meta : meta // ignore: cast_nullable_to_non_nullable +as Map,lastUsedAt: null == lastUsedAt ? _self.lastUsedAt : lastUsedAt // ignore: cast_nullable_to_non_nullable +as DateTime,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable +as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + + } // dart format on diff --git a/lib/models/auth.g.dart b/lib/models/auth.g.dart index 2c524c0..f14f327 100644 --- a/lib/models/auth.g.dart +++ b/lib/models/auth.g.dart @@ -155,3 +155,33 @@ Map _$SnAuthDeviceToJson(_SnAuthDevice instance) => 'sessions': instance.sessions.map((e) => e.toJson()).toList(), 'is_current': instance.isCurrent, }; + +_SnAccountConnection _$SnAccountConnectionFromJson(Map json) => + _SnAccountConnection( + id: json['id'] as String, + accountId: json['account_id'] as String, + provider: json['provider'] as String, + providedIdentifier: json['provided_identifier'] as String, + meta: json['meta'] as Map? ?? const {}, + lastUsedAt: DateTime.parse(json['last_used_at'] as String), + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + deletedAt: + json['deleted_at'] == null + ? null + : DateTime.parse(json['deleted_at'] as String), + ); + +Map _$SnAccountConnectionToJson( + _SnAccountConnection instance, +) => { + 'id': instance.id, + 'account_id': instance.accountId, + 'provider': instance.provider, + 'provided_identifier': instance.providedIdentifier, + 'meta': instance.meta, + 'last_used_at': instance.lastUsedAt.toIso8601String(), + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'deleted_at': instance.deletedAt?.toIso8601String(), +}; diff --git a/lib/screens/account.dart b/lib/screens/account.dart index a1d8a20..cd203a4 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -221,16 +221,6 @@ class AccountScreen extends HookConsumerWidget { context.router.push(RelationshipRoute()); }, ), - ListTile( - minTileHeight: 48, - leading: const Icon(Symbols.edit), - trailing: const Icon(Symbols.chevron_right), - contentPadding: EdgeInsets.symmetric(horizontal: 24), - title: Text('updateYourProfile').tr(), - onTap: () { - context.router.push(UpdateProfileRoute()); - }, - ), const Divider(height: 1).padding(vertical: 8), ListTile( minTileHeight: 48, @@ -242,6 +232,16 @@ class AccountScreen extends HookConsumerWidget { context.router.push(SettingsRoute()); }, ), + ListTile( + minTileHeight: 48, + leading: const Icon(Symbols.person_edit), + trailing: const Icon(Symbols.chevron_right), + contentPadding: EdgeInsets.symmetric(horizontal: 24), + title: Text('updateYourProfile').tr(), + onTap: () { + context.router.push(UpdateProfileRoute()); + }, + ), ListTile( minTileHeight: 48, leading: const Icon(Symbols.manage_accounts), diff --git a/lib/screens/account/me/settings.dart b/lib/screens/account/me/settings.dart index b655cfa..01f5fe9 100644 --- a/lib/screens/account/me/settings.dart +++ b/lib/screens/account/me/settings.dart @@ -14,6 +14,8 @@ import 'package:island/models/auth.dart'; import 'package:island/models/user.dart'; import 'package:island/pods/network.dart'; import 'package:island/pods/userinfo.dart'; +import 'package:island/screens/account/me/settings_auth_factors.dart'; +import 'package:island/screens/account/me/settings_contacts.dart'; import 'package:island/screens/auth/captcha.dart'; import 'package:island/screens/auth/login.dart'; import 'package:island/services/responsive.dart'; @@ -184,7 +186,7 @@ class AccountSettingsScreen extends HookConsumerWidget { showModalBottomSheet( context: context, builder: - (context) => _AuthFactorSheet(factor: factor), + (context) => AuthFactorSheet(factor: factor), ).then((value) { if (value == true) { ref.invalidate(authFactorsProvider); @@ -205,7 +207,7 @@ class AccountSettingsScreen extends HookConsumerWidget { onTap: () { showModalBottomSheet( context: context, - builder: (context) => const _AuthFactorNewSheet(), + builder: (context) => const AuthFactorNewSheet(), ).then((value) { if (value == true) { ref.invalidate(authFactorsProvider); @@ -289,7 +291,7 @@ class AccountSettingsScreen extends HookConsumerWidget { context: context, builder: (context) => - _ContactMethodSheet(contact: contact), + ContactMethodSheet(contact: contact), ).then((value) { if (value == true) { ref.invalidate(contactMethodsProvider); @@ -311,7 +313,7 @@ class AccountSettingsScreen extends HookConsumerWidget { showModalBottomSheet( context: context, builder: - (context) => const _ContactMethodNewSheet(), + (context) => const ContactMethodNewSheet(), ).then((value) { if (value == true) { ref.invalidate(contactMethodsProvider); @@ -471,599 +473,3 @@ class _SettingsSection extends StatelessWidget { ); } } - -class _AuthFactorSheet extends HookConsumerWidget { - final SnAuthFactor factor; - const _AuthFactorSheet({required this.factor}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - Future deleteFactor() async { - final confirm = await showConfirmAlert( - 'authFactorDeleteHint'.tr(), - 'authFactorDelete'.tr(), - ); - if (!confirm || !context.mounted) return; - try { - showLoadingModal(context); - final client = ref.read(apiClientProvider); - await client.delete('/accounts/me/factors/${factor.id}'); - if (context.mounted) Navigator.pop(context, true); - } catch (err) { - showErrorAlert(err); - } finally { - if (context.mounted) hideLoadingModal(context); - } - } - - Future disableFactor() async { - final confirm = await showConfirmAlert( - 'authFactorDisableHint'.tr(), - 'authFactorDisable'.tr(), - ); - if (!confirm || !context.mounted) return; - try { - showLoadingModal(context); - final client = ref.read(apiClientProvider); - await client.post('/accounts/me/factors/${factor.id}/disable'); - if (context.mounted) Navigator.pop(context, true); - } catch (err) { - showErrorAlert(err); - } finally { - if (context.mounted) hideLoadingModal(context); - } - } - - Future enableFactor() async { - String? password; - if ([3].contains(factor.type)) { - final confirmed = await showDialog( - context: context, - builder: - (context) => AlertDialog( - title: Text('authFactorEnable').tr(), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text('authFactorEnableHint').tr(), - const SizedBox(height: 16), - OtpTextField( - showCursor: false, - numberOfFields: 6, - obscureText: false, - showFieldAsBox: true, - focusedBorderColor: Theme.of(context).colorScheme.primary, - onSubmit: (String verificationCode) { - password = verificationCode; - }, - textStyle: Theme.of(context).textTheme.titleLarge!, - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: Text('cancel').tr(), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: Text('confirm').tr(), - ), - ], - ), - ); - if (confirmed == false || - (password?.isEmpty ?? true) || - !context.mounted) { - return; - } - } - try { - showLoadingModal(context); - final client = ref.read(apiClientProvider); - await client.post( - '/accounts/me/factors/${factor.id}/enable', - data: jsonEncode(password), - ); - if (context.mounted) Navigator.pop(context, true); - } catch (err) { - showErrorAlert(err); - } finally { - if (context.mounted) hideLoadingModal(context); - } - } - - return SheetScaffold( - titleText: 'authFactor'.tr(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(kFactorTypes[factor.type]!.$3, size: 32), - const Gap(8), - Text(kFactorTypes[factor.type]!.$1).tr(), - const Gap(4), - Text( - kFactorTypes[factor.type]!.$2, - style: Theme.of(context).textTheme.bodySmall, - ).tr(), - const Gap(10), - Row( - children: [ - if (factor.enabledAt == null) - Badge( - label: Text('authFactorDisabled'.tr()), - textColor: Theme.of(context).colorScheme.onSecondary, - backgroundColor: Theme.of(context).colorScheme.secondary, - ) - else - Badge( - label: Text('authFactorEnabled'.tr()), - textColor: Theme.of(context).colorScheme.onPrimary, - backgroundColor: Theme.of(context).colorScheme.primary, - ), - ], - ), - ], - ).padding(all: 20), - const Divider(height: 1), - if (factor.enabledAt != null) - ListTile( - leading: const Icon(Symbols.disabled_by_default), - title: Text('authFactorDisable').tr(), - onTap: disableFactor, - contentPadding: EdgeInsets.symmetric(horizontal: 20), - ) - else - ListTile( - leading: const Icon(Symbols.check_circle), - title: Text('authFactorEnable').tr(), - onTap: enableFactor, - contentPadding: EdgeInsets.symmetric(horizontal: 20), - ), - ListTile( - leading: const Icon(Symbols.delete), - title: Text('authFactorDelete').tr(), - onTap: deleteFactor, - contentPadding: EdgeInsets.symmetric(horizontal: 20), - ), - ], - ), - ); - } -} - -class _AuthFactorNewSheet extends HookConsumerWidget { - const _AuthFactorNewSheet(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final factorType = useState(0); - final secretController = useTextEditingController(); - - Future addFactor() async { - try { - showLoadingModal(context); - final apiClient = ref.read(apiClientProvider); - final resp = await apiClient.post( - '/accounts/me/factors', - data: {'type': factorType.value, 'secret': secretController.text}, - ); - final factor = SnAuthFactor.fromJson(resp.data); - if (!context.mounted) return; - hideLoadingModal(context); - if (factor.type == 3) { - showModalBottomSheet( - context: context, - builder: (context) => _AuthFactorNewAdditonalSheet(factor: factor), - ).then((_) { - if (context.mounted) { - showSnackBar(context, 'contactMethodVerificationNeeded'.tr()); - } - if (context.mounted) Navigator.pop(context, true); - }); - } else { - Navigator.pop(context, true); - } - } catch (err) { - showErrorAlert(err); - if (context.mounted) hideLoadingModal(context); - } - } - - return SheetScaffold( - titleText: 'authFactorNew'.tr(), - child: Column( - spacing: 16, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - DropdownButtonFormField( - value: factorType.value, - decoration: InputDecoration( - labelText: 'authFactor'.tr(), - border: const OutlineInputBorder(), - ), - items: - kFactorTypes.entries.map((entry) { - return DropdownMenuItem( - value: entry.key, - child: Row( - children: [ - Icon(entry.value.$3), - const Gap(8), - Text(entry.value.$1).tr(), - ], - ), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - factorType.value = value; - } - }, - ), - if (factorType.value == 0) - TextField( - controller: secretController, - decoration: InputDecoration( - prefixIcon: const Icon(Symbols.password_2), - labelText: 'authFactorSecret'.tr(), - hintText: 'authFactorSecretHint'.tr(), - border: const OutlineInputBorder(), - ), - onTapOutside: - (_) => FocusManager.instance.primaryFocus?.unfocus(), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text(kFactorTypes[factorType.value]!.$2).tr(), - ), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton.icon( - onPressed: addFactor, - icon: Icon(Symbols.add), - label: Text('create').tr(), - ), - ], - ), - ], - ).padding(horizontal: 20, vertical: 24), - ); - } -} - -class _AuthFactorNewAdditonalSheet extends StatelessWidget { - final SnAuthFactor factor; - const _AuthFactorNewAdditonalSheet({required this.factor}); - - @override - Widget build(BuildContext context) { - final uri = factor.createdResponse?['uri']; - - return SheetScaffold( - titleText: 'authFactorAdditional'.tr(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (uri != null) ...[ - const SizedBox(height: 16), - Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: QrImageView( - data: uri, - version: QrVersions.auto, - size: 200, - backgroundColor: Theme.of(context).colorScheme.surface, - foregroundColor: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - const Gap(16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'authFactorQrCodeScan'.tr(), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ] else ...[ - const SizedBox(height: 16), - Center( - child: Text( - 'authFactorNoQrCode'.tr(), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ], - const Gap(16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: TextButton.icon( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Symbols.check), - label: Text('next'.tr()), - ), - ), - ], - ), - ); - } -} - -class _ContactMethodSheet extends HookConsumerWidget { - final SnContactMethod contact; - const _ContactMethodSheet({required this.contact}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - Future deleteContactMethod() async { - final confirm = await showConfirmAlert( - 'contactMethodDeleteHint'.tr(), - 'contactMethodDelete'.tr(), - ); - if (!confirm || !context.mounted) return; - try { - showLoadingModal(context); - final client = ref.read(apiClientProvider); - await client.delete('/accounts/me/contacts/${contact.id}'); - if (context.mounted) Navigator.pop(context, true); - } catch (err) { - showErrorAlert(err); - } finally { - if (context.mounted) hideLoadingModal(context); - } - } - - Future verifyContactMethod() async { - try { - showLoadingModal(context); - final client = ref.read(apiClientProvider); - await client.post('/accounts/me/contacts/${contact.id}/verify'); - if (context.mounted) { - showSnackBar(context, 'contactMethodVerificationSent'.tr()); - } - } catch (err) { - showErrorAlert(err); - } finally { - if (context.mounted) hideLoadingModal(context); - } - } - - Future setContactMethodAsPrimary() async { - try { - showLoadingModal(context); - final client = ref.read(apiClientProvider); - await client.post('/accounts/me/contacts/${contact.id}/primary'); - if (context.mounted) Navigator.pop(context, true); - } catch (err) { - showErrorAlert(err); - } finally { - if (context.mounted) hideLoadingModal(context); - } - } - - return SheetScaffold( - titleText: 'contactMethod'.tr(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(switch (contact.type) { - 0 => Symbols.mail, - 1 => Symbols.phone, - _ => Symbols.home, - }, size: 32), - const Gap(8), - Text(switch (contact.type) { - 0 => 'contactMethodTypeEmail'.tr(), - 1 => 'contactMethodTypePhone'.tr(), - _ => 'contactMethodTypeAddress'.tr(), - }), - const Gap(4), - Text( - contact.content, - style: Theme.of(context).textTheme.bodySmall, - ), - const Gap(10), - Row( - children: [ - if (contact.verifiedAt == null) - Badge( - label: Text('contactMethodUnverified'.tr()), - textColor: Theme.of(context).colorScheme.onSecondary, - backgroundColor: Theme.of(context).colorScheme.secondary, - ) - else - Badge( - label: Text('contactMethodVerified'.tr()), - textColor: Theme.of(context).colorScheme.onPrimary, - backgroundColor: Theme.of(context).colorScheme.primary, - ), - if (contact.isPrimary) - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Badge( - label: Text('contactMethodPrimary'.tr()), - textColor: Theme.of(context).colorScheme.onTertiary, - backgroundColor: Theme.of(context).colorScheme.tertiary, - ), - ), - ], - ), - ], - ).padding(all: 20), - const Divider(height: 1), - if (contact.verifiedAt == null) - ListTile( - leading: const Icon(Symbols.verified), - title: Text('contactMethodVerify').tr(), - onTap: verifyContactMethod, - contentPadding: EdgeInsets.symmetric(horizontal: 20), - ), - if (contact.verifiedAt != null && !contact.isPrimary) - ListTile( - leading: const Icon(Symbols.star), - title: Text('contactMethodSetPrimary').tr(), - onTap: setContactMethodAsPrimary, - contentPadding: EdgeInsets.symmetric(horizontal: 20), - ), - ListTile( - leading: const Icon(Symbols.delete), - title: Text('contactMethodDelete').tr(), - onTap: deleteContactMethod, - contentPadding: EdgeInsets.symmetric(horizontal: 20), - ), - ], - ), - ); - } -} - -class _ContactMethodNewSheet extends HookConsumerWidget { - const _ContactMethodNewSheet(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final contactType = useState(0); - final contentController = useTextEditingController(); - - Future addContactMethod() async { - if (contentController.text.isEmpty) { - showSnackBar(context, 'contactMethodContentEmpty'.tr()); - return; - } - - try { - showLoadingModal(context); - final apiClient = ref.read(apiClientProvider); - await apiClient.post( - '/accounts/me/contacts', - data: {'type': contactType.value, 'content': contentController.text}, - ); - if (context.mounted) { - showSnackBar(context, 'contactMethodVerificationNeeded'.tr()); - Navigator.pop(context, true); - } - } catch (err) { - showErrorAlert(err); - } finally { - if (context.mounted) hideLoadingModal(context); - } - } - - return SheetScaffold( - titleText: 'contactMethodNew'.tr(), - child: Column( - spacing: 16, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - DropdownButtonFormField( - value: contactType.value, - decoration: InputDecoration( - labelText: 'contactMethodType'.tr(), - border: const OutlineInputBorder(), - ), - items: [ - DropdownMenuItem( - value: 0, - child: Row( - children: [ - Icon(Symbols.mail), - const Gap(8), - Text('contactMethodTypeEmail'.tr()), - ], - ), - ), - DropdownMenuItem( - value: 1, - child: Row( - children: [ - Icon(Symbols.phone), - const Gap(8), - Text('contactMethodTypePhone'.tr()), - ], - ), - ), - DropdownMenuItem( - value: 2, - child: Row( - children: [ - Icon(Symbols.home), - const Gap(8), - Text('contactMethodTypeAddress'.tr()), - ], - ), - ), - ], - onChanged: (value) { - if (value != null) { - contactType.value = value; - } - }, - ), - TextField( - controller: contentController, - decoration: InputDecoration( - prefixIcon: Icon(switch (contactType.value) { - 0 => Symbols.mail, - 1 => Symbols.phone, - _ => Symbols.home, - }), - labelText: switch (contactType.value) { - 0 => 'contactMethodTypeEmail'.tr(), - 1 => 'contactMethodTypePhone'.tr(), - _ => 'contactMethodTypeAddress'.tr(), - }, - hintText: switch (contactType.value) { - 0 => 'contactMethodEmailHint'.tr(), - 1 => 'contactMethodPhoneHint'.tr(), - _ => 'contactMethodAddressHint'.tr(), - }, - border: const OutlineInputBorder(), - ), - keyboardType: switch (contactType.value) { - 0 => TextInputType.emailAddress, - 1 => TextInputType.phone, - _ => TextInputType.multiline, - }, - maxLines: switch (contactType.value) { - 2 => 3, - _ => 1, - }, - onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: - Text(switch (contactType.value) { - 0 => 'contactMethodEmailDescription', - 1 => 'contactMethodPhoneDescription', - _ => 'contactMethodAddressDescription', - }).tr(), - ), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton.icon( - onPressed: addContactMethod, - icon: Icon(Symbols.add), - label: Text('create').tr(), - ), - ], - ), - ], - ).padding(horizontal: 20, vertical: 24), - ); - } -} diff --git a/lib/screens/account/me/settings_auth_factors.dart b/lib/screens/account/me/settings_auth_factors.dart new file mode 100644 index 0000000..a799949 --- /dev/null +++ b/lib/screens/account/me/settings_auth_factors.dart @@ -0,0 +1,342 @@ +import 'dart:convert'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_otp_text_field/flutter_otp_text_field.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/auth.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/screens/auth/login.dart'; +import 'package:island/widgets/alert.dart'; +import 'package:island/widgets/content/sheet.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class AuthFactorSheet extends HookConsumerWidget { + final SnAuthFactor factor; + const AuthFactorSheet({super.key, required this.factor}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + Future deleteFactor() async { + final confirm = await showConfirmAlert( + 'authFactorDeleteHint'.tr(), + 'authFactorDelete'.tr(), + ); + if (!confirm || !context.mounted) return; + try { + showLoadingModal(context); + final client = ref.read(apiClientProvider); + await client.delete('/accounts/me/factors/${factor.id}'); + if (context.mounted) Navigator.pop(context, true); + } catch (err) { + showErrorAlert(err); + } finally { + if (context.mounted) hideLoadingModal(context); + } + } + + Future disableFactor() async { + final confirm = await showConfirmAlert( + 'authFactorDisableHint'.tr(), + 'authFactorDisable'.tr(), + ); + if (!confirm || !context.mounted) return; + try { + showLoadingModal(context); + final client = ref.read(apiClientProvider); + await client.post('/accounts/me/factors/${factor.id}/disable'); + if (context.mounted) Navigator.pop(context, true); + } catch (err) { + showErrorAlert(err); + } finally { + if (context.mounted) hideLoadingModal(context); + } + } + + Future enableFactor() async { + String? password; + if ([3].contains(factor.type)) { + final confirmed = await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: Text('authFactorEnable').tr(), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('authFactorEnableHint').tr(), + const SizedBox(height: 16), + OtpTextField( + showCursor: false, + numberOfFields: 6, + obscureText: false, + showFieldAsBox: true, + focusedBorderColor: Theme.of(context).colorScheme.primary, + onSubmit: (String verificationCode) { + password = verificationCode; + }, + textStyle: Theme.of(context).textTheme.titleLarge!, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text('cancel').tr(), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text('confirm').tr(), + ), + ], + ), + ); + if (confirmed == false || + (password?.isEmpty ?? true) || + !context.mounted) { + return; + } + } + try { + showLoadingModal(context); + final client = ref.read(apiClientProvider); + await client.post( + '/accounts/me/factors/${factor.id}/enable', + data: jsonEncode(password), + ); + if (context.mounted) Navigator.pop(context, true); + } catch (err) { + showErrorAlert(err); + } finally { + if (context.mounted) hideLoadingModal(context); + } + } + + return SheetScaffold( + titleText: 'authFactor'.tr(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(kFactorTypes[factor.type]!.$3, size: 32), + const Gap(8), + Text(kFactorTypes[factor.type]!.$1).tr(), + const Gap(4), + Text( + kFactorTypes[factor.type]!.$2, + style: Theme.of(context).textTheme.bodySmall, + ).tr(), + const Gap(10), + Row( + children: [ + if (factor.enabledAt == null) + Badge( + label: Text('authFactorDisabled'.tr()), + textColor: Theme.of(context).colorScheme.onSecondary, + backgroundColor: Theme.of(context).colorScheme.secondary, + ) + else + Badge( + label: Text('authFactorEnabled'.tr()), + textColor: Theme.of(context).colorScheme.onPrimary, + backgroundColor: Theme.of(context).colorScheme.primary, + ), + ], + ), + ], + ).padding(all: 20), + const Divider(height: 1), + if (factor.enabledAt != null) + ListTile( + leading: const Icon(Symbols.disabled_by_default), + title: Text('authFactorDisable').tr(), + onTap: disableFactor, + contentPadding: EdgeInsets.symmetric(horizontal: 20), + ) + else + ListTile( + leading: const Icon(Symbols.check_circle), + title: Text('authFactorEnable').tr(), + onTap: enableFactor, + contentPadding: EdgeInsets.symmetric(horizontal: 20), + ), + ListTile( + leading: const Icon(Symbols.delete), + title: Text('authFactorDelete').tr(), + onTap: deleteFactor, + contentPadding: EdgeInsets.symmetric(horizontal: 20), + ), + ], + ), + ); + } +} + +class AuthFactorNewSheet extends HookConsumerWidget { + const AuthFactorNewSheet({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final factorType = useState(0); + final secretController = useTextEditingController(); + + Future addFactor() async { + try { + showLoadingModal(context); + final apiClient = ref.read(apiClientProvider); + final resp = await apiClient.post( + '/accounts/me/factors', + data: {'type': factorType.value, 'secret': secretController.text}, + ); + final factor = SnAuthFactor.fromJson(resp.data); + if (!context.mounted) return; + hideLoadingModal(context); + if (factor.type == 3) { + showModalBottomSheet( + context: context, + builder: (context) => AuthFactorNewAdditonalSheet(factor: factor), + ).then((_) { + if (context.mounted) { + showSnackBar(context, 'contactMethodVerificationNeeded'.tr()); + } + if (context.mounted) Navigator.pop(context, true); + }); + } else { + Navigator.pop(context, true); + } + } catch (err) { + showErrorAlert(err); + if (context.mounted) hideLoadingModal(context); + } + } + + return SheetScaffold( + titleText: 'authFactorNew'.tr(), + child: Column( + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonFormField( + value: factorType.value, + decoration: InputDecoration( + labelText: 'authFactor'.tr(), + border: const OutlineInputBorder(), + ), + items: + kFactorTypes.entries.map((entry) { + return DropdownMenuItem( + value: entry.key, + child: Row( + children: [ + Icon(entry.value.$3), + const Gap(8), + Text(entry.value.$1).tr(), + ], + ), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + factorType.value = value; + } + }, + ), + if (factorType.value == 0) + TextField( + controller: secretController, + decoration: InputDecoration( + prefixIcon: const Icon(Symbols.password_2), + labelText: 'authFactorSecret'.tr(), + hintText: 'authFactorSecretHint'.tr(), + border: const OutlineInputBorder(), + ), + onTapOutside: + (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text(kFactorTypes[factorType.value]!.$2).tr(), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: addFactor, + icon: Icon(Symbols.add), + label: Text('create').tr(), + ), + ], + ), + ], + ).padding(horizontal: 20, vertical: 24), + ); + } +} + +class AuthFactorNewAdditonalSheet extends StatelessWidget { + final SnAuthFactor factor; + const AuthFactorNewAdditonalSheet({super.key, required this.factor}); + + @override + Widget build(BuildContext context) { + final uri = factor.createdResponse?['uri']; + + return SheetScaffold( + titleText: 'authFactorAdditional'.tr(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (uri != null) ...[ + const SizedBox(height: 16), + Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: QrImageView( + data: uri, + version: QrVersions.auto, + size: 200, + backgroundColor: Theme.of(context).colorScheme.surface, + foregroundColor: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + const Gap(16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'authFactorQrCodeScan'.tr(), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ] else ...[ + const SizedBox(height: 16), + Center( + child: Text( + 'authFactorNoQrCode'.tr(), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + const Gap(16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextButton.icon( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Symbols.check), + label: Text('next'.tr()), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/account/me/settings_contacts.dart b/lib/screens/account/me/settings_contacts.dart new file mode 100644 index 0000000..9bea06f --- /dev/null +++ b/lib/screens/account/me/settings_contacts.dart @@ -0,0 +1,281 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/user.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/widgets/alert.dart'; +import 'package:island/widgets/content/sheet.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class ContactMethodSheet extends HookConsumerWidget { + final SnContactMethod contact; + const ContactMethodSheet({super.key, required this.contact}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + Future deleteContactMethod() async { + final confirm = await showConfirmAlert( + 'contactMethodDeleteHint'.tr(), + 'contactMethodDelete'.tr(), + ); + if (!confirm || !context.mounted) return; + try { + showLoadingModal(context); + final client = ref.read(apiClientProvider); + await client.delete('/accounts/me/contacts/${contact.id}'); + if (context.mounted) Navigator.pop(context, true); + } catch (err) { + showErrorAlert(err); + } finally { + if (context.mounted) hideLoadingModal(context); + } + } + + Future verifyContactMethod() async { + try { + showLoadingModal(context); + final client = ref.read(apiClientProvider); + await client.post('/accounts/me/contacts/${contact.id}/verify'); + if (context.mounted) { + showSnackBar(context, 'contactMethodVerificationSent'.tr()); + } + } catch (err) { + showErrorAlert(err); + } finally { + if (context.mounted) hideLoadingModal(context); + } + } + + Future setContactMethodAsPrimary() async { + try { + showLoadingModal(context); + final client = ref.read(apiClientProvider); + await client.post('/accounts/me/contacts/${contact.id}/primary'); + if (context.mounted) Navigator.pop(context, true); + } catch (err) { + showErrorAlert(err); + } finally { + if (context.mounted) hideLoadingModal(context); + } + } + + return SheetScaffold( + titleText: 'contactMethod'.tr(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(switch (contact.type) { + 0 => Symbols.mail, + 1 => Symbols.phone, + _ => Symbols.home, + }, size: 32), + const Gap(8), + Text(switch (contact.type) { + 0 => 'contactMethodTypeEmail'.tr(), + 1 => 'contactMethodTypePhone'.tr(), + _ => 'contactMethodTypeAddress'.tr(), + }), + const Gap(4), + Text( + contact.content, + style: Theme.of(context).textTheme.bodySmall, + ), + const Gap(10), + Row( + children: [ + if (contact.verifiedAt == null) + Badge( + label: Text('contactMethodUnverified'.tr()), + textColor: Theme.of(context).colorScheme.onSecondary, + backgroundColor: Theme.of(context).colorScheme.secondary, + ) + else + Badge( + label: Text('contactMethodVerified'.tr()), + textColor: Theme.of(context).colorScheme.onPrimary, + backgroundColor: Theme.of(context).colorScheme.primary, + ), + if (contact.isPrimary) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Badge( + label: Text('contactMethodPrimary'.tr()), + textColor: Theme.of(context).colorScheme.onTertiary, + backgroundColor: Theme.of(context).colorScheme.tertiary, + ), + ), + ], + ), + ], + ).padding(all: 20), + const Divider(height: 1), + if (contact.verifiedAt == null) + ListTile( + leading: const Icon(Symbols.verified), + title: Text('contactMethodVerify').tr(), + onTap: verifyContactMethod, + contentPadding: EdgeInsets.symmetric(horizontal: 20), + ), + if (contact.verifiedAt != null && !contact.isPrimary) + ListTile( + leading: const Icon(Symbols.star), + title: Text('contactMethodSetPrimary').tr(), + onTap: setContactMethodAsPrimary, + contentPadding: EdgeInsets.symmetric(horizontal: 20), + ), + ListTile( + leading: const Icon(Symbols.delete), + title: Text('contactMethodDelete').tr(), + onTap: deleteContactMethod, + contentPadding: EdgeInsets.symmetric(horizontal: 20), + ), + ], + ), + ); + } +} + +class ContactMethodNewSheet extends HookConsumerWidget { + const ContactMethodNewSheet({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final contactType = useState(0); + final contentController = useTextEditingController(); + + Future addContactMethod() async { + if (contentController.text.isEmpty) { + showSnackBar(context, 'contactMethodContentEmpty'.tr()); + return; + } + + try { + showLoadingModal(context); + final apiClient = ref.read(apiClientProvider); + await apiClient.post( + '/accounts/me/contacts', + data: {'type': contactType.value, 'content': contentController.text}, + ); + if (context.mounted) { + showSnackBar(context, 'contactMethodVerificationNeeded'.tr()); + Navigator.pop(context, true); + } + } catch (err) { + showErrorAlert(err); + } finally { + if (context.mounted) hideLoadingModal(context); + } + } + + return SheetScaffold( + titleText: 'contactMethodNew'.tr(), + child: Column( + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonFormField( + value: contactType.value, + decoration: InputDecoration( + labelText: 'contactMethodType'.tr(), + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem( + value: 0, + child: Row( + children: [ + Icon(Symbols.mail), + const Gap(8), + Text('contactMethodTypeEmail'.tr()), + ], + ), + ), + DropdownMenuItem( + value: 1, + child: Row( + children: [ + Icon(Symbols.phone), + const Gap(8), + Text('contactMethodTypePhone'.tr()), + ], + ), + ), + DropdownMenuItem( + value: 2, + child: Row( + children: [ + Icon(Symbols.home), + const Gap(8), + Text('contactMethodTypeAddress'.tr()), + ], + ), + ), + ], + onChanged: (value) { + if (value != null) { + contactType.value = value; + } + }, + ), + TextField( + controller: contentController, + decoration: InputDecoration( + prefixIcon: Icon(switch (contactType.value) { + 0 => Symbols.mail, + 1 => Symbols.phone, + _ => Symbols.home, + }), + labelText: switch (contactType.value) { + 0 => 'contactMethodTypeEmail'.tr(), + 1 => 'contactMethodTypePhone'.tr(), + _ => 'contactMethodTypeAddress'.tr(), + }, + hintText: switch (contactType.value) { + 0 => 'contactMethodEmailHint'.tr(), + 1 => 'contactMethodPhoneHint'.tr(), + _ => 'contactMethodAddressHint'.tr(), + }, + border: const OutlineInputBorder(), + ), + keyboardType: switch (contactType.value) { + 0 => TextInputType.emailAddress, + 1 => TextInputType.phone, + _ => TextInputType.multiline, + }, + maxLines: switch (contactType.value) { + 2 => 3, + _ => 1, + }, + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: + Text(switch (contactType.value) { + 0 => 'contactMethodEmailDescription', + 1 => 'contactMethodPhoneDescription', + _ => 'contactMethodAddressDescription', + }).tr(), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: addContactMethod, + icon: Icon(Symbols.add), + label: Text('create').tr(), + ), + ], + ), + ], + ).padding(horizontal: 20, vertical: 24), + ); + } +}