Connection management

This commit is contained in:
LittleSheep 2025-06-16 01:25:03 +08:00
parent 7f26196e85
commit 00b3dc7be6
4 changed files with 486 additions and 6 deletions

View File

@ -128,6 +128,24 @@
"connectionConnected": "Connected", "connectionConnected": "Connected",
"connectionDisconnected": "Disconnected", "connectionDisconnected": "Disconnected",
"connectionReconnecting": "Reconnecting", "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", "checkIn": "Check In",
"checkInNone": "Not checked-in yet", "checkInNone": "Not checked-in yet",
"checkInNoneHint": "Get your fortune tips and daily rewards by checking in.", "checkInNoneHint": "Get your fortune tips and daily rewards by checking in.",

View File

@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:auto_route/annotations.dart'; 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/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/auth.dart'; 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_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/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';
@ -22,10 +19,8 @@ import 'package:island/services/responsive.dart';
import 'package:island/widgets/account/account_session_sheet.dart'; import 'package:island/widgets/account/account_session_sheet.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/response.dart'; import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@ -47,6 +42,15 @@ Future<List<SnContactMethod>> contactMethods(Ref ref) async {
.toList(); .toList();
} }
@riverpod
Future<List<SnAccountConnection>> accountConnections(Ref ref) async {
final client = ref.read(apiClientProvider);
final resp = await client.get('/accounts/me/connections');
return resp.data
.map<SnAccountConnection>((e) => SnAccountConnection.fromJson(e))
.toList();
}
@RoutePage() @RoutePage()
class AccountSettingsScreen extends HookConsumerWidget { class AccountSettingsScreen extends HookConsumerWidget {
const AccountSettingsScreen({super.key}); 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( ExpansionTile(
leading: const Icon( leading: const Icon(
Symbols.security, Symbols.security,

View File

@ -44,5 +44,26 @@ final contactMethodsProvider =
@Deprecated('Will be removed in 3.0. Use Ref instead') @Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element // ignore: unused_element
typedef ContactMethodsRef = AutoDisposeFutureProviderRef<List<SnContactMethod>>; typedef ContactMethodsRef = AutoDisposeFutureProviderRef<List<SnContactMethod>>;
String _$accountConnectionsHash() =>
r'38a309d596e0ea2539cd92ea86984e1e4fb346e4';
/// See also [accountConnections].
@ProviderFor(accountConnections)
final accountConnectionsProvider =
AutoDisposeFutureProvider<List<SnAccountConnection>>.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<List<SnAccountConnection>>;
// ignore_for_file: type=lint // 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 // 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

View File

@ -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<void> 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<String>('apple');
// List of available providers
final providers = ['apple', 'microsoft', 'google', 'github', 'discord'];
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://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<String>(
value: selectedProvider.value,
decoration: InputDecoration(
prefixIcon: Icon(getProviderIcon(selectedProvider.value)),
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(),
);
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<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(),
),
);
}
}