Account contact settings

This commit is contained in:
2025-06-08 20:02:57 +08:00
parent 20e6cc4283
commit 723e17ff47
7 changed files with 692 additions and 20 deletions

View File

@ -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<List<SnAuthFactor>> authFactors(Ref ref) async {
return res.data.map<SnAuthFactor>((e) => SnAuthFactor.fromJson(e)).toList();
}
@riverpod
Future<List<SnContactMethod>> contactMethods(Ref ref) async {
final client = ref.read(apiClientProvider);
final resp = await client.get('/accounts/me/contacts');
return resp.data
.map<SnContactMethod>((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<void> 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<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),
);
}
}