🎨 Use feature based folder structure
This commit is contained in:
496
lib/accounts/account/me/account_settings.dart
Normal file
496
lib/accounts/account/me/account_settings.dart
Normal file
@@ -0,0 +1,496 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/account_devices.dart';
|
||||
import 'package:island/auth/auth_models/auth.dart';
|
||||
import 'package:island/accounts/accounts_models/account.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/accounts/accounts_pod.dart';
|
||||
import 'package:island/accounts/account/me/settings_auth_factors.dart';
|
||||
import 'package:island/accounts/account/me/settings_connections.dart';
|
||||
import 'package:island/accounts/account/me/settings_contacts.dart';
|
||||
import 'package:island/auth/captcha.dart';
|
||||
import 'package:island/auth/login.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/shared/widgets/app_scaffold.dart';
|
||||
import 'package:island/shared/widgets/response.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
part 'account_settings.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<SnAuthFactor>> authFactors(Ref ref) async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final res = await client.get('/pass/accounts/me/factors');
|
||||
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('/pass/accounts/me/contacts');
|
||||
return resp.data
|
||||
.map<SnContactMethod>((e) => SnContactMethod.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<List<SnAccountConnection>> accountConnections(Ref ref) async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final resp = await client.get('/pass/accounts/me/connections');
|
||||
return resp.data
|
||||
.map<SnAccountConnection>((e) => SnAccountConnection.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
|
||||
class AccountSettingsScreen extends HookConsumerWidget {
|
||||
const AccountSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDesktop =
|
||||
!kIsWeb && (Platform.isWindows || Platform.isMacOS || Platform.isLinux);
|
||||
|
||||
Future<void> requestAccountDeletion() async {
|
||||
final confirm = await showConfirmAlert(
|
||||
'accountDeletionHint'.tr(),
|
||||
'accountDeletion'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (!confirm || !context.mounted) return;
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.delete('/pass/accounts/me');
|
||||
if (context.mounted) {
|
||||
showSnackBar('accountDeletionSent'.tr());
|
||||
}
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> requestResetPassword() async {
|
||||
final confirm = await showConfirmAlert(
|
||||
'accountPasswordChangeDescription'.tr(),
|
||||
'accountPasswordChange'.tr(),
|
||||
);
|
||||
if (!confirm || !context.mounted) return;
|
||||
final captchaTk = await CaptchaScreen.show(context);
|
||||
if (captchaTk == null) return;
|
||||
try {
|
||||
if (context.mounted) showLoadingModal(context);
|
||||
final userInfo = ref.read(userInfoProvider);
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.post(
|
||||
'/pass/accounts/recovery/password',
|
||||
data: {'account': userInfo.value!.name, 'captcha_token': captchaTk},
|
||||
);
|
||||
if (context.mounted) {
|
||||
showSnackBar('accountPasswordChangeSent'.tr());
|
||||
}
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
}
|
||||
|
||||
final authFactors = ref.watch(authFactorsProvider);
|
||||
|
||||
// Group settings into categories for better organization
|
||||
final securitySettings = [
|
||||
ListTile(
|
||||
minLeadingWidth: 48,
|
||||
leading: const Icon(Symbols.devices),
|
||||
title: Text('authSessions').tr(),
|
||||
subtitle: Text('authSessionsDescription').tr().fontSize(12),
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => const AccountSessionSheet(),
|
||||
);
|
||||
},
|
||||
),
|
||||
ExpansionTile(
|
||||
leading: const Icon(
|
||||
Symbols.link,
|
||||
).alignment(Alignment.centerLeft).width(48),
|
||||
title: Text('accountConnections').tr(),
|
||||
subtitle: Text('accountConnectionsDescription').tr().fontSize(12),
|
||||
tilePadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
children: [
|
||||
ref
|
||||
.watch(accountConnectionsProvider)
|
||||
.when(
|
||||
data: (connections) => Column(
|
||||
children: [
|
||||
for (final connection in connections)
|
||||
ListTile(
|
||||
minLeadingWidth: 48,
|
||||
contentPadding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 17,
|
||||
top: 2,
|
||||
bottom: 4,
|
||||
),
|
||||
title: Text(
|
||||
getLocalizedProviderName(connection.provider),
|
||||
).tr(),
|
||||
subtitle: connection.meta['email'] != null
|
||||
? Text(connection.meta['email'])
|
||||
: Text(connection.providedIdentifier),
|
||||
leading: CircleAvatar(
|
||||
child: getProviderIcon(
|
||||
connection.provider,
|
||||
size: 16,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
).padding(top: 4),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
AccountConnectionSheet(connection: connection),
|
||||
).then((value) {
|
||||
if (value == true) {
|
||||
ref.invalidate(accountConnectionsProvider);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
if (connections.isNotEmpty) const Divider(height: 1),
|
||||
ListTile(
|
||||
minLeadingWidth: 48,
|
||||
contentPadding: const EdgeInsets.only(
|
||||
left: 24,
|
||||
right: 17,
|
||||
),
|
||||
title: Text('accountConnectionAdd').tr(),
|
||||
leading: const Icon(Symbols.add),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
const AccountConnectionNewSheet(),
|
||||
).then((value) {
|
||||
if (value == true) {
|
||||
ref.invalidate(accountConnectionsProvider);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
error: (err, _) => ResponseErrorWidget(
|
||||
error: err,
|
||||
onRetry: () => ref.invalidate(accountConnectionsProvider),
|
||||
),
|
||||
loading: () => const ResponseLoadingWidget(),
|
||||
),
|
||||
],
|
||||
),
|
||||
ExpansionTile(
|
||||
leading: const Icon(
|
||||
Symbols.security,
|
||||
).alignment(Alignment.centerLeft).width(48),
|
||||
title: Text('accountAuthFactor').tr(),
|
||||
subtitle: Text('accountAuthFactorDescription').tr().fontSize(12),
|
||||
tilePadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
children: [
|
||||
authFactors.when(
|
||||
data: (factors) => Column(
|
||||
children: [
|
||||
for (final factor in factors)
|
||||
ListTile(
|
||||
minLeadingWidth: 48,
|
||||
contentPadding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 17,
|
||||
top: 2,
|
||||
bottom: 4,
|
||||
),
|
||||
title: Text(
|
||||
kFactorTypes[factor.type]!.$1,
|
||||
style: factor.enabledAt == null
|
||||
? TextStyle(decoration: TextDecoration.lineThrough)
|
||||
: null,
|
||||
).tr(),
|
||||
subtitle: Text(
|
||||
kFactorTypes[factor.type]!.$2,
|
||||
style: factor.enabledAt == null
|
||||
? TextStyle(decoration: TextDecoration.lineThrough)
|
||||
: null,
|
||||
).tr(),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: factor.enabledAt == null
|
||||
? Theme.of(context).colorScheme.secondaryContainer
|
||||
: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Icon(kFactorTypes[factor.type]!.$3),
|
||||
).padding(top: 4),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
isThreeLine: true,
|
||||
onTap: () {
|
||||
if (factor.type == 0) {
|
||||
requestResetPassword();
|
||||
return;
|
||||
}
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => AuthFactorSheet(factor: factor),
|
||||
).then((value) {
|
||||
if (value == true) {
|
||||
ref.invalidate(authFactorsProvider);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
if (factors.isNotEmpty) Divider(height: 1),
|
||||
ListTile(
|
||||
minLeadingWidth: 48,
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
title: Text('authFactorNew').tr(),
|
||||
leading: const Icon(Symbols.add),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => const AuthFactorNewSheet(),
|
||||
).then((value) {
|
||||
if (value == true) {
|
||||
ref.invalidate(authFactorsProvider);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
error: (err, _) => ResponseErrorWidget(
|
||||
error: err,
|
||||
onRetry: () => ref.invalidate(authFactorsProvider),
|
||||
),
|
||||
loading: () => ResponseLoadingWidget(),
|
||||
),
|
||||
],
|
||||
),
|
||||
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 = [
|
||||
ListTile(
|
||||
minLeadingWidth: 48,
|
||||
title: Text('accountDeletion').tr(),
|
||||
subtitle: Text('accountDeletionDescription').tr().fontSize(12),
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
leading: const Icon(Symbols.delete_forever, color: Colors.red),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: requestAccountDeletion,
|
||||
),
|
||||
];
|
||||
|
||||
// Create a responsive layout based on screen width
|
||||
Widget buildSettingsList() {
|
||||
return Column(
|
||||
spacing: 16,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SettingsSection(
|
||||
title: 'accountSecurityTitle',
|
||||
children: securitySettings,
|
||||
),
|
||||
_SettingsSection(
|
||||
title: 'accountDangerZoneTitle',
|
||||
children: dangerZoneSettings,
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16);
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('accountSettings').tr(),
|
||||
actions: isDesktop
|
||||
? [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.help_outline),
|
||||
onPressed: () {
|
||||
// Show help dialog
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('accountSettingsHelp').tr(),
|
||||
content: Text('accountSettingsHelpContent').tr(),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('Close').tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
body: Focus(
|
||||
autofocus: true,
|
||||
onKeyEvent: (node, event) {
|
||||
// Add keyboard shortcuts for desktop
|
||||
if (isDesktop &&
|
||||
event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
Navigator.of(context).pop();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: buildSettingsList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper widget for displaying settings sections with titles
|
||||
class _SettingsSection extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
|
||||
const _SettingsSection({required this.title, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text(
|
||||
title.tr(),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
...children,
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
134
lib/accounts/account/me/account_settings.g.dart
Normal file
134
lib/accounts/account/me/account_settings.g.dart
Normal file
@@ -0,0 +1,134 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'account_settings.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(authFactors)
|
||||
final authFactorsProvider = AuthFactorsProvider._();
|
||||
|
||||
final class AuthFactorsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<SnAuthFactor>>,
|
||||
List<SnAuthFactor>,
|
||||
FutureOr<List<SnAuthFactor>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<List<SnAuthFactor>>,
|
||||
$FutureProvider<List<SnAuthFactor>> {
|
||||
AuthFactorsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'authFactorsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$authFactorsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<SnAuthFactor>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<SnAuthFactor>> create(Ref ref) {
|
||||
return authFactors(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$authFactorsHash() => r'ed87d7dbd421fef0a5620416727c3dc598c97ef5';
|
||||
|
||||
@ProviderFor(contactMethods)
|
||||
final contactMethodsProvider = ContactMethodsProvider._();
|
||||
|
||||
final class ContactMethodsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<SnContactMethod>>,
|
||||
List<SnContactMethod>,
|
||||
FutureOr<List<SnContactMethod>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<List<SnContactMethod>>,
|
||||
$FutureProvider<List<SnContactMethod>> {
|
||||
ContactMethodsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'contactMethodsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$contactMethodsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<SnContactMethod>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<SnContactMethod>> create(Ref ref) {
|
||||
return contactMethods(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$contactMethodsHash() => r'1d3d03e9ffbf36126236558ead22cb7d88bb9cb2';
|
||||
|
||||
@ProviderFor(accountConnections)
|
||||
final accountConnectionsProvider = AccountConnectionsProvider._();
|
||||
|
||||
final class AccountConnectionsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<SnAccountConnection>>,
|
||||
List<SnAccountConnection>,
|
||||
FutureOr<List<SnAccountConnection>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<List<SnAccountConnection>>,
|
||||
$FutureProvider<List<SnAccountConnection>> {
|
||||
AccountConnectionsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'accountConnectionsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$accountConnectionsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<SnAccountConnection>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<SnAccountConnection>> create(Ref ref) {
|
||||
return accountConnections(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$accountConnectionsHash() =>
|
||||
r'33c10b98962ede6c428d4028c0d5f2f12ff0eb22';
|
||||
1021
lib/accounts/account/me/profile_update.dart
Normal file
1021
lib/accounts/account/me/profile_update.dart
Normal file
File diff suppressed because it is too large
Load Diff
358
lib/accounts/account/me/settings_auth_factors.dart
Normal file
358
lib/accounts/account/me/settings_auth_factors.dart
Normal file
@@ -0,0 +1,358 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
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/auth/auth_models/auth.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/auth/login.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/core/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(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (!confirm || !context.mounted) return;
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.delete('/pass/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('/pass/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(
|
||||
'/pass/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(
|
||||
'/pass/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('contactMethodVerificationNeeded'.tr());
|
||||
}
|
||||
if (context.mounted) Navigator.pop(context, true);
|
||||
});
|
||||
} else {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
}
|
||||
|
||||
final width = math.min(400, MediaQuery.of(context).size.width);
|
||||
|
||||
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 ([0].contains(factorType.value))
|
||||
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(),
|
||||
)
|
||||
else if ([4].contains(factorType.value))
|
||||
OtpTextField(
|
||||
showCursor: false,
|
||||
numberOfFields: 6,
|
||||
obscureText: false,
|
||||
showFieldAsBox: true,
|
||||
focusedBorderColor: Theme.of(context).colorScheme.primary,
|
||||
fieldWidth: (width / 6) - 10,
|
||||
keyboardType: TextInputType.number,
|
||||
onSubmit: (String verificationCode) {
|
||||
secretController.text = verificationCode;
|
||||
},
|
||||
textStyle: Theme.of(context).textTheme.titleLarge!,
|
||||
),
|
||||
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()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
382
lib/accounts/account/me/settings_connections.dart
Normal file
382
lib/accounts/account/me/settings_connections.dart
Normal file
@@ -0,0 +1,382 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/auth/auth_models/auth.dart';
|
||||
import 'package:island/core/config.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/accounts/account/me/account_settings.dart';
|
||||
import 'package:island/core/utils/text.dart';
|
||||
import 'package:island/core/services/time.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:island/shared/widgets/response.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
// Helper function to get provider icon and localized name
|
||||
Widget getProviderIcon(String provider, {double size = 24, Color? color}) {
|
||||
final providerLower = provider.toLowerCase();
|
||||
|
||||
// Check if we have an SVG for this provider
|
||||
switch (providerLower) {
|
||||
case 'apple':
|
||||
case 'microsoft':
|
||||
case 'google':
|
||||
case 'github':
|
||||
case 'discord':
|
||||
case 'afdian':
|
||||
case 'steam':
|
||||
return SvgPicture.asset(
|
||||
'assets/images/oidc/$providerLower.svg',
|
||||
width: size,
|
||||
height: size,
|
||||
color: color,
|
||||
);
|
||||
case 'spotify':
|
||||
return Image.asset(
|
||||
'assets/images/oidc/spotify.png',
|
||||
width: size,
|
||||
height: size,
|
||||
color: color,
|
||||
);
|
||||
default:
|
||||
return Icon(Symbols.link, size: size);
|
||||
}
|
||||
}
|
||||
|
||||
String getLocalizedProviderName(String provider) {
|
||||
switch (provider.toLowerCase()) {
|
||||
case 'apple':
|
||||
return 'accountConnectionProviderApple'.tr();
|
||||
case 'microsoft':
|
||||
return 'accountConnectionProviderMicrosoft'.tr();
|
||||
case 'google':
|
||||
return 'accountConnectionProviderGoogle'.tr();
|
||||
case 'github':
|
||||
return 'accountConnectionProviderGithub'.tr();
|
||||
case 'discord':
|
||||
return 'accountConnectionProviderDiscord'.tr();
|
||||
case 'afdian':
|
||||
return 'accountConnectionProviderAfdian'.tr();
|
||||
case 'spotify':
|
||||
return 'accountConnectionProviderSpotify'.tr();
|
||||
case 'steam':
|
||||
return 'accountConnectionProviderSteam'.tr();
|
||||
default:
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
|
||||
class AccountConnectionSheet extends HookConsumerWidget {
|
||||
final SnAccountConnection connection;
|
||||
const AccountConnectionSheet({super.key, required this.connection});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Future<void> deleteConnection() async {
|
||||
final confirm = await showConfirmAlert(
|
||||
'accountConnectionDeleteHint'.tr(),
|
||||
'accountConnectionDelete'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (!confirm || !context.mounted) return;
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.delete('/pass/accounts/me/connections/${connection.id}');
|
||||
if (context.mounted) Navigator.pop(context, true);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
}
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'accountConnections'.tr(),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
getProviderIcon(
|
||||
connection.provider,
|
||||
size: 32,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(getLocalizedProviderName(connection.provider)).tr(),
|
||||
const Gap(4),
|
||||
if (connection.meta.isNotEmpty)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final meta in connection.meta.entries)
|
||||
Text(
|
||||
'${meta.key.replaceAll('_', ' ').capitalizeEachWord()}: ${meta.value}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
connection.providedIdentifier,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
connection.lastUsedAt.formatSystem(),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
).opacity(0.85),
|
||||
],
|
||||
).padding(all: 20),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.delete),
|
||||
title: Text('accountConnectionDelete').tr(),
|
||||
onTap: deleteConnection,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AccountConnectionNewSheet extends HookConsumerWidget {
|
||||
const AccountConnectionNewSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedProvider = useState<String>('apple');
|
||||
|
||||
// List of available providers
|
||||
final providers = [
|
||||
'apple',
|
||||
'microsoft',
|
||||
'google',
|
||||
'github',
|
||||
'discord',
|
||||
'afdian',
|
||||
'spotify',
|
||||
'steam',
|
||||
];
|
||||
|
||||
Future<void> addConnection() async {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
|
||||
switch (selectedProvider.value.toLowerCase()) {
|
||||
case 'apple':
|
||||
try {
|
||||
final credential = await SignInWithApple.getAppleIDCredential(
|
||||
scopes: [AppleIDAuthorizationScopes.email],
|
||||
webAuthenticationOptions: WebAuthenticationOptions(
|
||||
clientId: 'dev.solsynth.solarpass',
|
||||
redirectUri: Uri.parse('https://solian.app/auth/callback'),
|
||||
),
|
||||
);
|
||||
|
||||
if (context.mounted) showLoadingModal(context);
|
||||
|
||||
await client.post(
|
||||
'/pass/auth/connect/apple/mobile',
|
||||
data: {
|
||||
'identity_token': credential.identityToken!,
|
||||
'authorization_code': credential.authorizationCode,
|
||||
},
|
||||
);
|
||||
if (context.mounted) {
|
||||
showSnackBar('accountConnectionAddSuccess'.tr());
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err is SignInWithAppleAuthorizationException) return;
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
default:
|
||||
final serverUrl = ref.watch(serverUrlProvider);
|
||||
final accessToken = ref.watch(tokenProvider);
|
||||
launchUrlString(
|
||||
'$serverUrl/pass/auth/login/${selectedProvider.value}?tk=${accessToken!.token}',
|
||||
);
|
||||
if (context.mounted) Navigator.pop(context, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'accountConnectionAdd'.tr(),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
spacing: 16,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
value: selectedProvider.value,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: getProviderIcon(
|
||||
selectedProvider.value,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
).padding(all: 16),
|
||||
labelText: 'accountConnectionProvider'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: providers.map((String provider) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: provider,
|
||||
child: Row(
|
||||
children: [Text(getLocalizedProviderName(provider)).tr()],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (String? newValue) {
|
||||
if (newValue != null) {
|
||||
selectedProvider.value = newValue;
|
||||
}
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text('accountConnectionDescription'.tr()),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: addConnection,
|
||||
icon: const Icon(Symbols.add),
|
||||
label: Text('next').tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 20, vertical: 24),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AccountConnectionsSheet extends HookConsumerWidget {
|
||||
const AccountConnectionsSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final connections = ref.watch(accountConnectionsProvider);
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'accountConnections'.tr(),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.add),
|
||||
onPressed: () async {
|
||||
final result = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => const AccountConnectionNewSheet(),
|
||||
);
|
||||
if (result == true) {
|
||||
ref.invalidate(accountConnectionsProvider);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
child: connections.when(
|
||||
data: (data) => RefreshIndicator(
|
||||
onRefresh: () =>
|
||||
Future.sync(() => ref.invalidate(accountConnectionsProvider)),
|
||||
child: data.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'accountConnectionsEmpty'.tr(),
|
||||
textAlign: TextAlign.center,
|
||||
).padding(horizontal: 32),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final connection = data[index];
|
||||
return Dismissible(
|
||||
key: Key('connection-${connection.id}'),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
color: Colors.red,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: const Icon(Icons.delete, color: Colors.white),
|
||||
),
|
||||
confirmDismiss: (direction) async {
|
||||
final confirm = await showConfirmAlert(
|
||||
'accountConnectionDeleteHint'.tr(),
|
||||
'accountConnectionDelete'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (confirm && context.mounted) {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.delete(
|
||||
'/pass/accounts/me/connections/${connection.id}',
|
||||
);
|
||||
ref.invalidate(accountConnectionsProvider);
|
||||
return true;
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: ListTile(
|
||||
leading: getProviderIcon(
|
||||
connection.provider,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
title: Text(
|
||||
getLocalizedProviderName(connection.provider),
|
||||
).tr(),
|
||||
subtitle: connection.meta['email'] != null
|
||||
? Text(connection.meta['email'])
|
||||
: Text(connection.providedIdentifier),
|
||||
trailing: Text(
|
||||
DateFormat.yMd().format(
|
||||
connection.lastUsedAt.toLocal(),
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
onTap: () async {
|
||||
final result = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) =>
|
||||
AccountConnectionSheet(connection: connection),
|
||||
);
|
||||
if (result == true) {
|
||||
ref.invalidate(accountConnectionsProvider);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
error: (err, _) => ResponseErrorWidget(
|
||||
error: err,
|
||||
onRetry: () => ref.invalidate(accountConnectionsProvider),
|
||||
),
|
||||
loading: () => const ResponseLoadingWidget(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
341
lib/accounts/account/me/settings_contacts.dart
Normal file
341
lib/accounts/account/me/settings_contacts.dart
Normal file
@@ -0,0 +1,341 @@
|
||||
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/accounts/accounts_models/account.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/core/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(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (!confirm || !context.mounted) return;
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.delete('/pass/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('/pass/accounts/me/contacts/${contact.id}/verify');
|
||||
if (context.mounted) {
|
||||
showSnackBar('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('/pass/accounts/me/contacts/${contact.id}/primary');
|
||||
if (context.mounted) Navigator.pop(context, true);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> makeContactMethodPublic() async {
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.post('/pass/accounts/me/contacts/${contact.id}/public');
|
||||
if (context.mounted) Navigator.pop(context, true);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> makeContactMethodPrivate() async {
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.delete('/pass/accounts/me/contacts/${contact.id}/public');
|
||||
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,
|
||||
),
|
||||
),
|
||||
if (contact.isPublic)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Badge(
|
||||
label: Text('contactMethodPublic'.tr()),
|
||||
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
if (!contact.isPublic)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Badge(
|
||||
label: Text('contactMethodPrivate'.tr()),
|
||||
textColor: Theme.of(context).colorScheme.onSurface,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
).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),
|
||||
),
|
||||
if (contact.verifiedAt != null && !contact.isPublic)
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.public),
|
||||
title: Text('contactMethodMakePublic').tr(),
|
||||
onTap: makeContactMethodPublic,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 20),
|
||||
),
|
||||
if (contact.verifiedAt != null && contact.isPublic)
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.visibility_off),
|
||||
title: Text('contactMethodMakePrivate').tr(),
|
||||
onTap: makeContactMethodPrivate,
|
||||
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('contactMethodContentEmpty'.tr());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
await apiClient.post(
|
||||
'/pass/accounts/me/contacts',
|
||||
data: {'type': contactType.value, 'content': contentController.text},
|
||||
);
|
||||
if (context.mounted) {
|
||||
showSnackBar('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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user