408 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			408 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
import 'package:easy_localization/easy_localization.dart';
 | 
						|
import 'package:flutter/foundation.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/models/auth.dart';
 | 
						|
import 'package:island/pods/config.dart';
 | 
						|
import 'package:island/pods/network.dart';
 | 
						|
import 'package:island/screens/account/me/account_settings.dart';
 | 
						|
import 'package:island/screens/auth/oidc.native.dart';
 | 
						|
import 'package:island/utils/text.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';
 | 
						|
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':
 | 
						|
      return SvgPicture.asset(
 | 
						|
        'assets/images/oidc/$providerLower.svg',
 | 
						|
        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();
 | 
						|
    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('/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',
 | 
						|
    ];
 | 
						|
 | 
						|
    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);
 | 
						|
          }
 | 
						|
        case 'microsoft':
 | 
						|
        case 'google':
 | 
						|
        case 'github':
 | 
						|
        case 'discord':
 | 
						|
        case 'afdian':
 | 
						|
          if (kIsWeb) {
 | 
						|
            final serverUrl = ref.watch(serverUrlProvider);
 | 
						|
            final accessToken = ref.watch(tokenProvider);
 | 
						|
            launchUrlString(
 | 
						|
              '$serverUrl/pass/auth/login/${selectedProvider.value}?tk=${accessToken!.token}',
 | 
						|
            );
 | 
						|
          } else {
 | 
						|
            await Navigator.of(context, rootNavigator: true).push(
 | 
						|
              MaterialPageRoute(
 | 
						|
                builder:
 | 
						|
                    (context) => OidcScreen(
 | 
						|
                      provider: selectedProvider.value.toLowerCase(),
 | 
						|
                      title:
 | 
						|
                          'Connect with ${selectedProvider.value.capitalizeEachWord()}',
 | 
						|
                    ),
 | 
						|
              ),
 | 
						|
            );
 | 
						|
            if (context.mounted) Navigator.pop(context, true);
 | 
						|
          }
 | 
						|
          break;
 | 
						|
        default:
 | 
						|
          showSnackBar('accountConnectionAddError'.tr());
 | 
						|
          return;
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    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(),
 | 
						|
                              );
 | 
						|
                              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(),
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 |