diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 84b6631..b64161f 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -357,5 +357,44 @@ "typingHint": { "one": "{} is typing...", "other": "{} are typing..." - } + }, + "settingsAppearance": "Appearance", + "settingsServer": "Server", + "settingsBehavior": "Behavior", + "settingsDesktop": "Desktop", + "settingsKeyboardShortcuts": "Keyboard Shortcuts", + "settingsEnterToSendDesktopHint": "Press Enter to send messages, use Shift+Enter for new line.", + "settingsHelp": "Settings Help", + "settingsHelpContent": "This page allows you to manage your app settings, appearance, and behavior. If you need assistance, please contact support.", + "settingsKeyboardShortcutSearch": "Search", + "settingsKeyboardShortcutSettings": "Settings", + "settingsKeyboardShortcutNewMessage": "New Message", + "settingsKeyboardShortcutCloseDialog": "Close Dialog", + "close": "Close", + "contactMethod": "Contact Method", + "contactMethodType": "Contact Type", + "contactMethodTypeEmail": "Email", + "contactMethodTypePhone": "Phone", + "contactMethodTypeAddress": "Address", + "contactMethodEmailHint": "Enter your email address", + "contactMethodPhoneHint": "Enter your phone number", + "contactMethodAddressHint": "Enter your physical address", + "contactMethodEmailDescription": "Your email will be used for account recovery and notifications", + "contactMethodPhoneDescription": "Your phone number will be used for account recovery and notifications", + "contactMethodAddressDescription": "Your physical address will be used for shipping and billing purposes.", + "contactMethodVerified": "Verified", + "contactMethodUnverified": "Unverified", + "contactMethodVerify": "Verify Contact", + "contactMethodDelete": "Delete Contact", + "contactMethodNew": "New Contact Method", + "contactMethodContentEmpty": "Contact content cannot be empty", + "contactMethodVerificationSent": "Verification code sent to your contact method", + "contactMethodVerificationNeeded": "The contact method is added, but not verified yet. You can verify it by tapping it and select verify.", + "accountContactMethod": "Contact Methods", + "accountContactMethodDescription": "Manage your contact methods for account recovery and notifications", + "authFactorVerificationNeeded": "The auth factor is added, but it is not enabled yet. You can enable it by tapping it and enter the verification code.", + "contactMethodPrimary": "Primary", + "contactMethodSetPrimary": "Set as Primary", + "contactMethodSetPrimaryHint": "Set this contact method as your primary contact method for account recovery and notifications", + "contactMethodDeleteHint": "Are you sure to delete this contact method? This action cannot be undone." } diff --git a/lib/models/user.dart b/lib/models/user.dart index 19157aa..54f3543 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -85,6 +85,24 @@ sealed class SnAccountBadge with _$SnAccountBadge { _$SnAccountBadgeFromJson(json); } +@freezed +sealed class SnContactMethod with _$SnContactMethod { + const factory SnContactMethod({ + required String id, + required int type, + required DateTime? verifiedAt, + required bool isPrimary, + required String content, + required String accountId, + required DateTime createdAt, + required DateTime updatedAt, + required DateTime? deletedAt, + }) = _SnContactMethod; + + factory SnContactMethod.fromJson(Map json) => + _$SnContactMethodFromJson(json); +} + @freezed sealed class SnNotification with _$SnNotification { const factory SnNotification({ diff --git a/lib/models/user.freezed.dart b/lib/models/user.freezed.dart index 2b18074..b30b876 100644 --- a/lib/models/user.freezed.dart +++ b/lib/models/user.freezed.dart @@ -746,6 +746,163 @@ as DateTime?, } +/// @nodoc +mixin _$SnContactMethod { + + String get id; int get type; DateTime? get verifiedAt; bool get isPrimary; String get content; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; +/// Create a copy of SnContactMethod +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SnContactMethodCopyWith get copyWith => _$SnContactMethodCopyWithImpl(this as SnContactMethod, _$identity); + + /// Serializes this SnContactMethod to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnContactMethod&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.verifiedAt, verifiedAt) || other.verifiedAt == verifiedAt)&&(identical(other.isPrimary, isPrimary) || other.isPrimary == isPrimary)&&(identical(other.content, content) || other.content == content)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(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,type,verifiedAt,isPrimary,content,accountId,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnContactMethod(id: $id, type: $type, verifiedAt: $verifiedAt, isPrimary: $isPrimary, content: $content, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $SnContactMethodCopyWith<$Res> { + factory $SnContactMethodCopyWith(SnContactMethod value, $Res Function(SnContactMethod) _then) = _$SnContactMethodCopyWithImpl; +@useResult +$Res call({ + String id, int type, DateTime? verifiedAt, bool isPrimary, String content, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + + + +} +/// @nodoc +class _$SnContactMethodCopyWithImpl<$Res> + implements $SnContactMethodCopyWith<$Res> { + _$SnContactMethodCopyWithImpl(this._self, this._then); + + final SnContactMethod _self; + final $Res Function(SnContactMethod) _then; + +/// Create a copy of SnContactMethod +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? verifiedAt = freezed,Object? isPrimary = null,Object? content = null,Object? accountId = 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,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as int,verifiedAt: freezed == verifiedAt ? _self.verifiedAt : verifiedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,isPrimary: null == isPrimary ? _self.isPrimary : isPrimary // ignore: cast_nullable_to_non_nullable +as bool,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable +as String,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable +as String,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 _SnContactMethod implements SnContactMethod { + const _SnContactMethod({required this.id, required this.type, required this.verifiedAt, required this.isPrimary, required this.content, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}); + factory _SnContactMethod.fromJson(Map json) => _$SnContactMethodFromJson(json); + +@override final String id; +@override final int type; +@override final DateTime? verifiedAt; +@override final bool isPrimary; +@override final String content; +@override final String accountId; +@override final DateTime createdAt; +@override final DateTime updatedAt; +@override final DateTime? deletedAt; + +/// Create a copy of SnContactMethod +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SnContactMethodCopyWith<_SnContactMethod> get copyWith => __$SnContactMethodCopyWithImpl<_SnContactMethod>(this, _$identity); + +@override +Map toJson() { + return _$SnContactMethodToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnContactMethod&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.verifiedAt, verifiedAt) || other.verifiedAt == verifiedAt)&&(identical(other.isPrimary, isPrimary) || other.isPrimary == isPrimary)&&(identical(other.content, content) || other.content == content)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(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,type,verifiedAt,isPrimary,content,accountId,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnContactMethod(id: $id, type: $type, verifiedAt: $verifiedAt, isPrimary: $isPrimary, content: $content, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$SnContactMethodCopyWith<$Res> implements $SnContactMethodCopyWith<$Res> { + factory _$SnContactMethodCopyWith(_SnContactMethod value, $Res Function(_SnContactMethod) _then) = __$SnContactMethodCopyWithImpl; +@override @useResult +$Res call({ + String id, int type, DateTime? verifiedAt, bool isPrimary, String content, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + + + +} +/// @nodoc +class __$SnContactMethodCopyWithImpl<$Res> + implements _$SnContactMethodCopyWith<$Res> { + __$SnContactMethodCopyWithImpl(this._self, this._then); + + final _SnContactMethod _self; + final $Res Function(_SnContactMethod) _then; + +/// Create a copy of SnContactMethod +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? verifiedAt = freezed,Object? isPrimary = null,Object? content = null,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_SnContactMethod( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as int,verifiedAt: freezed == verifiedAt ? _self.verifiedAt : verifiedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,isPrimary: null == isPrimary ? _self.isPrimary : isPrimary // ignore: cast_nullable_to_non_nullable +as bool,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable +as String,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable +as String,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 mixin _$SnNotification { diff --git a/lib/models/user.g.dart b/lib/models/user.g.dart index e504b25..45546f3 100644 --- a/lib/models/user.g.dart +++ b/lib/models/user.g.dart @@ -157,6 +157,38 @@ Map _$SnAccountBadgeToJson(_SnAccountBadge instance) => 'deleted_at': instance.deletedAt?.toIso8601String(), }; +_SnContactMethod _$SnContactMethodFromJson(Map json) => + _SnContactMethod( + id: json['id'] as String, + type: (json['type'] as num).toInt(), + verifiedAt: + json['verified_at'] == null + ? null + : DateTime.parse(json['verified_at'] as String), + isPrimary: json['is_primary'] as bool, + content: json['content'] as String, + accountId: json['account_id'] 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 _$SnContactMethodToJson(_SnContactMethod instance) => + { + 'id': instance.id, + 'type': instance.type, + 'verified_at': instance.verifiedAt?.toIso8601String(), + 'is_primary': instance.isPrimary, + 'content': instance.content, + 'account_id': instance.accountId, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'deleted_at': instance.deletedAt?.toIso8601String(), + }; + _SnNotification _$SnNotificationFromJson(Map json) => _SnNotification( createdAt: DateTime.parse(json['created_at'] as String), diff --git a/lib/screens/account/me/settings.dart b/lib/screens/account/me/settings.dart index f2790d4..923b43d 100644 --- a/lib/screens/account/me/settings.dart +++ b/lib/screens/account/me/settings.dart @@ -11,6 +11,7 @@ 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/models/user.dart'; import 'package:island/pods/network.dart'; import 'package:island/pods/userinfo.dart'; import 'package:island/screens/auth/captcha.dart'; @@ -35,6 +36,15 @@ Future> authFactors(Ref ref) async { return res.data.map((e) => SnAuthFactor.fromJson(e)).toList(); } +@riverpod +Future> contactMethods(Ref ref) async { + final client = ref.read(apiClientProvider); + final resp = await client.get('/accounts/me/contacts'); + return resp.data + .map((e) => SnContactMethod.fromJson(e)) + .toList(); +} + @RoutePage() class AccountSettingsScreen extends HookConsumerWidget { const AccountSettingsScreen({super.key}); @@ -52,6 +62,7 @@ class AccountSettingsScreen extends HookConsumerWidget { ); if (!confirm || !context.mounted) return; try { + showLoadingModal(context); final client = ref.read(apiClientProvider); await client.delete('/accounts/me'); if (context.mounted) { @@ -59,6 +70,8 @@ class AccountSettingsScreen extends HookConsumerWidget { } } catch (err) { showErrorAlert(err); + } finally { + if (context.mounted) hideLoadingModal(context); } } @@ -73,6 +86,7 @@ class AccountSettingsScreen extends HookConsumerWidget { ).push(MaterialPageRoute(builder: (context) => CaptchaScreen())); if (captchaTk == null) return; try { + showLoadingModal(context); final userInfo = ref.read(userInfoProvider); final client = ref.read(apiClientProvider); await client.post( @@ -84,6 +98,8 @@ class AccountSettingsScreen extends HookConsumerWidget { } } catch (err) { showErrorAlert(err); + } finally { + if (context.mounted) hideLoadingModal(context); } } @@ -208,6 +224,112 @@ class AccountSettingsScreen extends HookConsumerWidget { ), ], ), + ExpansionTile( + leading: const Icon( + Symbols.contact_mail, + ).alignment(Alignment.centerLeft).width(48), + title: Text('accountContactMethod').tr(), + subtitle: Text('accountContactMethodDescription').tr().fontSize(12), + tilePadding: const EdgeInsets.only(left: 24, right: 17), + children: [ + ref + .watch(contactMethodsProvider) + .when( + data: + (contacts) => Column( + children: [ + for (final contact in contacts) + ListTile( + minLeadingWidth: 48, + contentPadding: const EdgeInsets.only( + left: 16, + right: 17, + top: 2, + bottom: 4, + ), + title: Text( + contact.content, + style: + contact.verifiedAt == null + ? TextStyle( + decoration: TextDecoration.lineThrough, + ) + : null, + ), + subtitle: Text( + contact.type == 0 + ? 'contactMethodTypeEmail'.tr() + : 'contactMethodTypePhone'.tr(), + style: + contact.verifiedAt == null + ? TextStyle( + decoration: TextDecoration.lineThrough, + ) + : null, + ), + leading: CircleAvatar( + backgroundColor: + contact.verifiedAt == null + ? Theme.of( + context, + ).colorScheme.secondaryContainer + : Theme.of( + context, + ).colorScheme.primaryContainer, + child: Icon( + contact.type == 0 + ? Symbols.mail + : Symbols.phone, + ), + ).padding(top: 4), + trailing: const Icon(Symbols.chevron_right), + isThreeLine: false, + onTap: () { + showModalBottomSheet( + context: context, + builder: + (context) => + _ContactMethodSheet(contact: contact), + ).then((value) { + if (value == true) { + ref.invalidate(contactMethodsProvider); + } + }); + }, + ), + if (contacts.isNotEmpty) const Divider(height: 1), + ListTile( + minLeadingWidth: 48, + contentPadding: const EdgeInsets.only( + left: 24, + right: 17, + ), + title: Text('contactMethodNew').tr(), + leading: const Icon(Symbols.add), + trailing: const Icon(Symbols.chevron_right), + onTap: () { + showModalBottomSheet( + context: context, + builder: + (context) => const _ContactMethodNewSheet(), + ).then((value) { + if (value == true) { + ref.invalidate(contactMethodsProvider); + } + }); + }, + ), + ], + ), + error: + (err, _) => ResponseErrorWidget( + error: err, + onRetry: () => ref.invalidate(contactMethodsProvider), + ), + loading: () => const ResponseLoadingWidget(), + ), + ], + ), ]; final dangerZoneSettings = [ @@ -363,11 +485,14 @@ class _AuthFactorSheet extends HookConsumerWidget { ); 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); } } @@ -378,11 +503,14 @@ class _AuthFactorSheet extends HookConsumerWidget { ); 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); } } @@ -431,6 +559,7 @@ class _AuthFactorSheet extends HookConsumerWidget { } } try { + showLoadingModal(context); final client = ref.read(apiClientProvider); await client.post( '/accounts/me/factors/${factor.id}/enable', @@ -439,6 +568,8 @@ class _AuthFactorSheet extends HookConsumerWidget { if (context.mounted) Navigator.pop(context, true); } catch (err) { showErrorAlert(err); + } finally { + if (context.mounted) hideLoadingModal(context); } } @@ -464,13 +595,13 @@ class _AuthFactorSheet extends HookConsumerWidget { children: [ if (factor.enabledAt == null) Badge( - label: Text('Disabled'), + label: Text('authFactorDisabled'.tr()), textColor: Theme.of(context).colorScheme.onSecondary, backgroundColor: Theme.of(context).colorScheme.secondary, ) else Badge( - label: Text('Enabled'), + label: Text('authFactorEnabled'.tr()), textColor: Theme.of(context).colorScheme.onPrimary, backgroundColor: Theme.of(context).colorScheme.primary, ), @@ -515,6 +646,7 @@ class _AuthFactorNewSheet extends HookConsumerWidget { Future addFactor() async { try { + showLoadingModal(context); final apiClient = ref.read(apiClientProvider); final resp = await apiClient.post( '/accounts/me/factors', @@ -522,11 +654,15 @@ class _AuthFactorNewSheet extends HookConsumerWidget { ); 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 { @@ -534,6 +670,7 @@ class _AuthFactorNewSheet extends HookConsumerWidget { } } catch (err) { showErrorAlert(err); + if (context.mounted) hideLoadingModal(context); } } @@ -660,3 +797,273 @@ class _AuthFactorNewAdditonalSheet extends StatelessWidget { ); } } + +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.g.dart b/lib/screens/account/me/settings.g.dart index 912f6be..224b3de 100644 --- a/lib/screens/account/me/settings.g.dart +++ b/lib/screens/account/me/settings.g.dart @@ -25,5 +25,24 @@ final authFactorsProvider = @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef AuthFactorsRef = AutoDisposeFutureProviderRef>; +String _$contactMethodsHash() => r'4d7952fc196dce4dc646314565a49c115fd1d292'; + +/// See also [contactMethods]. +@ProviderFor(contactMethods) +final contactMethodsProvider = + AutoDisposeFutureProvider>.internal( + contactMethods, + name: r'contactMethodsProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$contactMethodsHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ContactMethodsRef = AutoDisposeFutureProviderRef>; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 5858a2a..d133292 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -158,7 +158,7 @@ class SettingsScreen extends HookConsumerWidget { actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: Text('Cancel').tr(), + child: Text('cancel').tr(), ), TextButton( onPressed: () { @@ -167,7 +167,7 @@ class SettingsScreen extends HookConsumerWidget { .setAppColorScheme(selectedColor.value); Navigator.of(context).pop(); }, - child: Text('Confirm').tr(), + child: Text('confirm').tr(), ), ], ); @@ -402,19 +402,19 @@ class SettingsScreen extends HookConsumerWidget { children: [ _ShortcutRow( shortcut: 'Ctrl+F', - description: 'Search', + description: 'settingsKeyboardShortcutSearch'.tr(), ), _ShortcutRow( shortcut: 'Ctrl+,', - description: 'Settings', + description: 'settingsKeyboardShortcutSettings'.tr(), ), _ShortcutRow( shortcut: 'Ctrl+N', - description: 'New Message', + description: 'settingsKeyboardShortcutNewMessage'.tr(), ), _ShortcutRow( shortcut: 'Esc', - description: 'Close Dialog', + description: 'settingsKeyboardShortcutCloseDialog'.tr(), ), // Add more shortcuts as needed ], @@ -423,7 +423,7 @@ class SettingsScreen extends HookConsumerWidget { actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: Text('Close').tr(), + child: Text('close').tr(), ), ], ), @@ -445,10 +445,10 @@ class SettingsScreen extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ _SettingsSection( - title: 'Appearance', + title: 'settingsAppearance'.tr(), children: appearanceSettings, ), - _SettingsSection(title: 'Server', children: serverSettings), + _SettingsSection(title: 'settingsServer'.tr(), children: serverSettings), ], ), ), @@ -457,12 +457,12 @@ class SettingsScreen extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ _SettingsSection( - title: 'Behavior', + title: 'settingsBehavior'.tr(), children: behaviorSettings, ), if (desktopSettings.isNotEmpty) _SettingsSection( - title: 'Desktop', + title: 'settingsDesktop'.tr(), children: desktopSettings, ), ], @@ -475,11 +475,11 @@ class SettingsScreen extends HookConsumerWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _SettingsSection(title: 'Appearance', children: appearanceSettings), - _SettingsSection(title: 'Server', children: serverSettings), - _SettingsSection(title: 'Behavior', children: behaviorSettings), + _SettingsSection(title: 'settingsAppearance'.tr(), children: appearanceSettings), + _SettingsSection(title: 'settingsServer'.tr(), children: serverSettings), + _SettingsSection(title: 'settingsBehavior'.tr(), children: behaviorSettings), if (desktopSettings.isNotEmpty) - _SettingsSection(title: 'Desktop', children: desktopSettings), + _SettingsSection(title: 'settingsDesktop'.tr(), children: desktopSettings), ], ); } @@ -488,7 +488,7 @@ class SettingsScreen extends HookConsumerWidget { return AppScaffold( noBackground: false, appBar: AppBar( - title: Text('Settings').tr(), + title: Text('settings').tr(), actions: isDesktop ? [ @@ -505,7 +505,7 @@ class SettingsScreen extends HookConsumerWidget { actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: Text('Close').tr(), + child: Text('close').tr(), ), ], ),