From 00b3dc7be661cea636afe0b34c476ec974c00144 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Mon, 16 Jun 2025 01:25:03 +0800 Subject: [PATCH] :sparkles: Connection management --- assets/i18n/en-US.json | 18 + lib/screens/account/me/settings.dart | 114 +++++- lib/screens/account/me/settings.g.dart | 21 ++ .../account/me/settings_connections.dart | 339 ++++++++++++++++++ 4 files changed, 486 insertions(+), 6 deletions(-) create mode 100644 lib/screens/account/me/settings_connections.dart diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 5d06851..1b70a7d 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -128,6 +128,24 @@ "connectionConnected": "Connected", "connectionDisconnected": "Disconnected", "connectionReconnecting": "Reconnecting", + "accountConnections": "Account Connections", + "accountConnectionsDescription": "Manage your external account connections", + "accountConnectionAdd": "Add Connection", + "accountConnectionDelete": "Delete Connection", + "accountConnectionDeleteHint": "Are you sure you want to delete this connection? This action cannot be undone.", + "accountConnectionsEmpty": "No connections found. Add a connection to get started.", + "accountConnectionProvider": "Provider", + "accountConnectionProviderHint": "Enter provider name", + "accountConnectionIdentifier": "Identifier", + "accountConnectionIdentifierHint": "Enter your identifier for this provider", + "accountConnectionDescription": "Add a connection to link your account with external services.", + "accountConnectionAddSuccess": "Connection added successfully.", + "accountConnectionAddError": "Unable to setup connection.", + "accountConnectionProviderApple": "Apple", + "accountConnectionProviderMicrosoft": "Microsoft", + "accountConnectionProviderGoogle": "Google", + "accountConnectionProviderGithub": "GitHub", + "accountConnectionProviderDiscord": "Discord", "checkIn": "Check In", "checkInNone": "Not checked-in yet", "checkInNoneHint": "Get your fortune tips and daily rewards by checking in.", diff --git a/lib/screens/account/me/settings.dart b/lib/screens/account/me/settings.dart index 01f5fe9..9c38fb5 100644 --- a/lib/screens/account/me/settings.dart +++ b/lib/screens/account/me/settings.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:io'; import 'package:auto_route/annotations.dart'; @@ -6,15 +5,13 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.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/models/user.dart'; import 'package:island/pods/network.dart'; import 'package:island/pods/userinfo.dart'; import 'package:island/screens/account/me/settings_auth_factors.dart'; +import 'package:island/screens/account/me/settings_connections.dart'; import 'package:island/screens/account/me/settings_contacts.dart'; import 'package:island/screens/auth/captcha.dart'; import 'package:island/screens/auth/login.dart'; @@ -22,10 +19,8 @@ import 'package:island/services/responsive.dart'; import 'package:island/widgets/account/account_session_sheet.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; -import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/response.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'package:qr_flutter/qr_flutter.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:styled_widget/styled_widget.dart'; @@ -47,6 +42,15 @@ Future> contactMethods(Ref ref) async { .toList(); } +@riverpod +Future> accountConnections(Ref ref) async { + final client = ref.read(apiClientProvider); + final resp = await client.get('/accounts/me/connections'); + return resp.data + .map((e) => SnAccountConnection.fromJson(e)) + .toList(); +} + @RoutePage() class AccountSettingsScreen extends HookConsumerWidget { const AccountSettingsScreen({super.key}); @@ -124,6 +128,104 @@ class AccountSettingsScreen extends HookConsumerWidget { ); }, ), + 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.isNotEmpty + ? Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + for (final meta + in connection.meta.entries) + Text( + '${meta.key.split('_').map((word) => word[0].toUpperCase() + word.substring(1)).join(' ')}: ${meta.value}', + style: const TextStyle( + fontSize: 12, + ), + ), + ], + ) + : Text(connection.providedIdentifier), + leading: CircleAvatar( + child: Icon(getProviderIcon(connection.provider)), + ).padding(top: 4), + trailing: const Icon(Symbols.chevron_right), + isThreeLine: true, + 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, diff --git a/lib/screens/account/me/settings.g.dart b/lib/screens/account/me/settings.g.dart index 224b3de..33b613e 100644 --- a/lib/screens/account/me/settings.g.dart +++ b/lib/screens/account/me/settings.g.dart @@ -44,5 +44,26 @@ final contactMethodsProvider = @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef ContactMethodsRef = AutoDisposeFutureProviderRef>; +String _$accountConnectionsHash() => + r'38a309d596e0ea2539cd92ea86984e1e4fb346e4'; + +/// See also [accountConnections]. +@ProviderFor(accountConnections) +final accountConnectionsProvider = + AutoDisposeFutureProvider>.internal( + accountConnections, + name: r'accountConnectionsProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$accountConnectionsHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AccountConnectionsRef = + AutoDisposeFutureProviderRef>; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/screens/account/me/settings_connections.dart b/lib/screens/account/me/settings_connections.dart new file mode 100644 index 0000000..b0f512a --- /dev/null +++ b/lib/screens/account/me/settings_connections.dart @@ -0,0 +1,339 @@ +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/auth.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/screens/account/me/settings.dart'; +import 'package:island/services/time.dart'; +import 'package:island/widgets/alert.dart'; +import 'package:island/widgets/content/sheet.dart'; +import 'package:island/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'; + +// Helper function to get provider icon and localized name +IconData getProviderIcon(String provider) { + switch (provider.toLowerCase()) { + case 'apple': + return Icons.apple; + case 'microsoft': + return Symbols.window; // Microsoft icon alternative + case 'google': + return Symbols.g_translate; // Google icon alternative + case 'github': + return Symbols.code; // GitHub icon + case 'discord': + return Symbols.forum; // Discord icon alternative + default: + return Symbols.link; + } +} + +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(); + 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 deleteConnection() async { + final confirm = await showConfirmAlert( + 'accountConnectionDeleteHint'.tr(), + 'accountConnectionDelete'.tr(), + ); + if (!confirm || !context.mounted) return; + try { + showLoadingModal(context); + final client = ref.read(apiClientProvider); + await client.delete('/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: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(getProviderIcon(connection.provider), size: 32), + const Gap(8), + Text(getLocalizedProviderName(connection.provider)).tr(), + const Gap(4), + 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('apple'); + + // List of available providers + final providers = ['apple', 'microsoft', 'google', 'github', 'discord']; + + Future 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://nt.solian.app/auth/callback/apple', + ), + ), + ); + + if (context.mounted) showLoadingModal(context); + + await client.post( + '/auth/connect/apple/mobile', + data: { + 'identity_token': credential.identityToken!, + 'authorization_code': credential.authorizationCode, + }, + ); + if (context.mounted) { + showSnackBar(context, 'accountConnectionAddSuccess'.tr()); + Navigator.pop(context, true); + } + } catch (err) { + if (err is SignInWithAppleCredentialsException) return; + showErrorAlert(err); + } finally { + if (context.mounted) hideLoadingModal(context); + } + case 'microsoft': + case 'google': + case 'github': + case 'discord': + break; + default: + showSnackBar(context, 'accountConnectionAddError'.tr()); + return; + } + } + + return SheetScaffold( + titleText: 'accountConnectionAdd'.tr(), + child: Column( + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonFormField( + value: selectedProvider.value, + decoration: InputDecoration( + prefixIcon: Icon(getProviderIcon(selectedProvider.value)), + labelText: 'accountConnectionProvider'.tr(), + border: const OutlineInputBorder(), + ), + items: + providers.map((String provider) { + return DropdownMenuItem( + 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( + 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(), + ); + if (confirm && context.mounted) { + try { + final client = ref.read(apiClientProvider); + await client.delete( + '/accounts/me/connections/${connection.id}', + ); + ref.invalidate(accountConnectionsProvider); + return true; + } catch (err) { + showErrorAlert(err); + return false; + } + } + return false; + }, + child: ListTile( + leading: Icon( + getProviderIcon(connection.provider), + ), + title: + Text( + getLocalizedProviderName( + connection.provider, + ), + ).tr(), + subtitle: Text(connection.providedIdentifier), + trailing: Text( + DateFormat.yMd().format( + connection.lastUsedAt.toLocal(), + ), + style: Theme.of(context).textTheme.bodySmall, + ), + onTap: () async { + final result = await showModalBottomSheet( + 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(), + ), + ); + } +}