🎨 Split up account settings page
This commit is contained in:
parent
3e5669780f
commit
7f26196e85
@ -28,7 +28,7 @@
|
|||||||
"fieldCannotBeEmpty": "This field cannot be empty.",
|
"fieldCannotBeEmpty": "This field cannot be empty.",
|
||||||
"fieldEmailAddressMustBeValid": "The email address must be valid.",
|
"fieldEmailAddressMustBeValid": "The email address must be valid.",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"updateYourProfile": "Edit Profile",
|
"updateYourProfile": "Profile Settings",
|
||||||
"accountBasicInfo": "Basic Info",
|
"accountBasicInfo": "Basic Info",
|
||||||
"accountProfile": "Your Profile",
|
"accountProfile": "Your Profile",
|
||||||
"saveChanges": "Save Changes",
|
"saveChanges": "Save Changes",
|
||||||
|
@ -91,3 +91,21 @@ sealed class SnAuthDevice with _$SnAuthDevice {
|
|||||||
factory SnAuthDevice.fromJson(Map<String, dynamic> json) =>
|
factory SnAuthDevice.fromJson(Map<String, dynamic> json) =>
|
||||||
_$SnAuthDeviceFromJson(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<String, dynamic> meta,
|
||||||
|
required DateTime lastUsedAt,
|
||||||
|
required DateTime createdAt,
|
||||||
|
required DateTime updatedAt,
|
||||||
|
required DateTime? deletedAt,
|
||||||
|
}) = _SnAccountConnection;
|
||||||
|
|
||||||
|
factory SnAccountConnection.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SnAccountConnectionFromJson(json);
|
||||||
|
}
|
||||||
|
@ -847,6 +847,169 @@ as bool,
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$SnAccountConnection {
|
||||||
|
|
||||||
|
String get id; String get accountId; String get provider; String get providedIdentifier; Map<String, dynamic> 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<SnAccountConnection> get copyWith => _$SnAccountConnectionCopyWithImpl<SnAccountConnection>(this as SnAccountConnection, _$identity);
|
||||||
|
|
||||||
|
/// Serializes this SnAccountConnection to a JSON map.
|
||||||
|
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic>,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<String, dynamic> meta = const {}, required this.lastUsedAt, required this.createdAt, required this.updatedAt, required this.deletedAt}): _meta = meta;
|
||||||
|
factory _SnAccountConnection.fromJson(Map<String, dynamic> json) => _$SnAccountConnectionFromJson(json);
|
||||||
|
|
||||||
|
@override final String id;
|
||||||
|
@override final String accountId;
|
||||||
|
@override final String provider;
|
||||||
|
@override final String providedIdentifier;
|
||||||
|
final Map<String, dynamic> _meta;
|
||||||
|
@override@JsonKey() Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic>,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
|
// dart format on
|
||||||
|
@ -155,3 +155,33 @@ Map<String, dynamic> _$SnAuthDeviceToJson(_SnAuthDevice instance) =>
|
|||||||
'sessions': instance.sessions.map((e) => e.toJson()).toList(),
|
'sessions': instance.sessions.map((e) => e.toJson()).toList(),
|
||||||
'is_current': instance.isCurrent,
|
'is_current': instance.isCurrent,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_SnAccountConnection _$SnAccountConnectionFromJson(Map<String, dynamic> 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<String, dynamic>? ?? 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<String, dynamic> _$SnAccountConnectionToJson(
|
||||||
|
_SnAccountConnection instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'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(),
|
||||||
|
};
|
||||||
|
@ -221,16 +221,6 @@ class AccountScreen extends HookConsumerWidget {
|
|||||||
context.router.push(RelationshipRoute());
|
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),
|
const Divider(height: 1).padding(vertical: 8),
|
||||||
ListTile(
|
ListTile(
|
||||||
minTileHeight: 48,
|
minTileHeight: 48,
|
||||||
@ -242,6 +232,16 @@ class AccountScreen extends HookConsumerWidget {
|
|||||||
context.router.push(SettingsRoute());
|
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(
|
ListTile(
|
||||||
minTileHeight: 48,
|
minTileHeight: 48,
|
||||||
leading: const Icon(Symbols.manage_accounts),
|
leading: const Icon(Symbols.manage_accounts),
|
||||||
|
@ -14,6 +14,8 @@ import 'package:island/models/auth.dart';
|
|||||||
import 'package:island/models/user.dart';
|
import 'package:island/models/user.dart';
|
||||||
import 'package:island/pods/network.dart';
|
import 'package:island/pods/network.dart';
|
||||||
import 'package:island/pods/userinfo.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/captcha.dart';
|
||||||
import 'package:island/screens/auth/login.dart';
|
import 'package:island/screens/auth/login.dart';
|
||||||
import 'package:island/services/responsive.dart';
|
import 'package:island/services/responsive.dart';
|
||||||
@ -184,7 +186,7 @@ class AccountSettingsScreen extends HookConsumerWidget {
|
|||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
builder:
|
builder:
|
||||||
(context) => _AuthFactorSheet(factor: factor),
|
(context) => AuthFactorSheet(factor: factor),
|
||||||
).then((value) {
|
).then((value) {
|
||||||
if (value == true) {
|
if (value == true) {
|
||||||
ref.invalidate(authFactorsProvider);
|
ref.invalidate(authFactorsProvider);
|
||||||
@ -205,7 +207,7 @@ class AccountSettingsScreen extends HookConsumerWidget {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => const _AuthFactorNewSheet(),
|
builder: (context) => const AuthFactorNewSheet(),
|
||||||
).then((value) {
|
).then((value) {
|
||||||
if (value == true) {
|
if (value == true) {
|
||||||
ref.invalidate(authFactorsProvider);
|
ref.invalidate(authFactorsProvider);
|
||||||
@ -289,7 +291,7 @@ class AccountSettingsScreen extends HookConsumerWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
builder:
|
builder:
|
||||||
(context) =>
|
(context) =>
|
||||||
_ContactMethodSheet(contact: contact),
|
ContactMethodSheet(contact: contact),
|
||||||
).then((value) {
|
).then((value) {
|
||||||
if (value == true) {
|
if (value == true) {
|
||||||
ref.invalidate(contactMethodsProvider);
|
ref.invalidate(contactMethodsProvider);
|
||||||
@ -311,7 +313,7 @@ class AccountSettingsScreen extends HookConsumerWidget {
|
|||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
builder:
|
builder:
|
||||||
(context) => const _ContactMethodNewSheet(),
|
(context) => const ContactMethodNewSheet(),
|
||||||
).then((value) {
|
).then((value) {
|
||||||
if (value == true) {
|
if (value == true) {
|
||||||
ref.invalidate(contactMethodsProvider);
|
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<void> 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<void> 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<void> enableFactor() async {
|
|
||||||
String? password;
|
|
||||||
if ([3].contains(factor.type)) {
|
|
||||||
final confirmed = await showDialog<bool>(
|
|
||||||
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<int>(0);
|
|
||||||
final secretController = useTextEditingController();
|
|
||||||
|
|
||||||
Future<void> 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<int>(
|
|
||||||
value: factorType.value,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'authFactor'.tr(),
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
items:
|
|
||||||
kFactorTypes.entries.map((entry) {
|
|
||||||
return DropdownMenuItem<int>(
|
|
||||||
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<void> 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<void> 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<void> 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<int>(0);
|
|
||||||
final contentController = useTextEditingController();
|
|
||||||
|
|
||||||
Future<void> 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<int>(
|
|
||||||
value: contactType.value,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'contactMethodType'.tr(),
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
DropdownMenuItem<int>(
|
|
||||||
value: 0,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Symbols.mail),
|
|
||||||
const Gap(8),
|
|
||||||
Text('contactMethodTypeEmail'.tr()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
DropdownMenuItem<int>(
|
|
||||||
value: 1,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Symbols.phone),
|
|
||||||
const Gap(8),
|
|
||||||
Text('contactMethodTypePhone'.tr()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
DropdownMenuItem<int>(
|
|
||||||
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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
342
lib/screens/account/me/settings_auth_factors.dart
Normal file
342
lib/screens/account/me/settings_auth_factors.dart
Normal file
@ -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<void> 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<void> 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<void> enableFactor() async {
|
||||||
|
String? password;
|
||||||
|
if ([3].contains(factor.type)) {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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<int>(0);
|
||||||
|
final secretController = useTextEditingController();
|
||||||
|
|
||||||
|
Future<void> 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<int>(
|
||||||
|
value: factorType.value,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'authFactor'.tr(),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items:
|
||||||
|
kFactorTypes.entries.map((entry) {
|
||||||
|
return DropdownMenuItem<int>(
|
||||||
|
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()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
281
lib/screens/account/me/settings_contacts.dart
Normal file
281
lib/screens/account/me/settings_contacts.dart
Normal file
@ -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<void> 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<void> 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<void> 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<int>(0);
|
||||||
|
final contentController = useTextEditingController();
|
||||||
|
|
||||||
|
Future<void> 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<int>(
|
||||||
|
value: contactType.value,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'contactMethodType'.tr(),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
DropdownMenuItem<int>(
|
||||||
|
value: 0,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Symbols.mail),
|
||||||
|
const Gap(8),
|
||||||
|
Text('contactMethodTypeEmail'.tr()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DropdownMenuItem<int>(
|
||||||
|
value: 1,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Symbols.phone),
|
||||||
|
const Gap(8),
|
||||||
|
Text('contactMethodTypePhone'.tr()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DropdownMenuItem<int>(
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user