324 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			324 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'dart:convert';
 | |
| 
 | |
| import 'package:easy_localization/easy_localization.dart';
 | |
| import 'package:flutter/material.dart';
 | |
| import 'package:hooks_riverpod/hooks_riverpod.dart';
 | |
| import 'package:island/models/account.dart';
 | |
| import 'package:island/pods/network.dart';
 | |
| import 'package:island/services/responsive.dart';
 | |
| import 'package:island/services/time.dart';
 | |
| import 'package:island/services/udid.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:riverpod_annotation/riverpod_annotation.dart';
 | |
| import 'package:styled_widget/styled_widget.dart';
 | |
| import 'package:island/widgets/extended_refresh_indicator.dart';
 | |
| 
 | |
| part 'account_devices.g.dart';
 | |
| 
 | |
| @riverpod
 | |
| Future<List<SnAuthDeviceWithChallenge>> authDevices(Ref ref) async {
 | |
|   final resp = await ref
 | |
|       .watch(apiClientProvider)
 | |
|       .get('/pass/accounts/me/devices');
 | |
|   final currentId = await getUdid();
 | |
|   final data =
 | |
|       resp.data.map<SnAuthDeviceWithChallenge>((e) {
 | |
|         final ele = SnAuthDeviceWithChallenge.fromJson(e);
 | |
|         return ele.copyWith(isCurrent: ele.deviceId == currentId);
 | |
|       }).toList();
 | |
|   return data;
 | |
| }
 | |
| 
 | |
| class _DeviceListTile extends StatelessWidget {
 | |
|   final SnAuthDeviceWithChallenge device;
 | |
|   final Function(String) updateDeviceLabel;
 | |
|   final Function(String) logoutDevice;
 | |
| 
 | |
|   const _DeviceListTile({
 | |
|     required this.device,
 | |
|     required this.updateDeviceLabel,
 | |
|     required this.logoutDevice,
 | |
|   });
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     return ExpansionTile(
 | |
|       title: Row(
 | |
|         spacing: 8,
 | |
|         children: [
 | |
|           Flexible(child: Text(device.deviceLabel ?? device.deviceName)),
 | |
|           if (device.isCurrent)
 | |
|             Row(
 | |
|               children: [
 | |
|                 Badge(
 | |
|                   backgroundColor: Theme.of(context).colorScheme.primary,
 | |
|                   label: Text(
 | |
|                     'authDeviceCurrent'.tr(),
 | |
|                     style: TextStyle(
 | |
|                       color: Theme.of(context).colorScheme.onPrimary,
 | |
|                     ),
 | |
|                   ),
 | |
|                 ),
 | |
|               ],
 | |
|             ),
 | |
|         ],
 | |
|       ),
 | |
|       subtitle: Column(
 | |
|         crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|         children: [
 | |
|           Text(
 | |
|             'lastActiveAt'.tr(
 | |
|               args: [device.challenges.first.createdAt.formatSystem()],
 | |
|             ),
 | |
|           ),
 | |
|         ],
 | |
|       ),
 | |
|       leading: Icon(switch (device.platform) {
 | |
|         0 => Icons.device_unknown, // Unidentified
 | |
|         1 => Icons.web, // Web
 | |
|         2 => Icons.phone_iphone, // iOS
 | |
|         3 => Icons.phone_android, // Android
 | |
|         4 => Icons.laptop_mac, // macOS
 | |
|         5 => Icons.window, // Windows
 | |
|         6 => Icons.computer, // Linux
 | |
|         _ => Icons.device_unknown, // fallback
 | |
|       }).padding(top: 4),
 | |
|       trailing:
 | |
|           isWideScreen(context)
 | |
|               ? Row(
 | |
|                 mainAxisSize: MainAxisSize.min,
 | |
|                 children: [
 | |
|                   IconButton(
 | |
|                     icon: Icon(Icons.edit),
 | |
|                     tooltip: 'authDeviceEditLabel'.tr(),
 | |
|                     onPressed: () => updateDeviceLabel(device.deviceId),
 | |
|                   ),
 | |
|                   if (!device.isCurrent)
 | |
|                     IconButton(
 | |
|                       icon: Icon(Icons.logout),
 | |
|                       tooltip: 'authDeviceLogout'.tr(),
 | |
|                       onPressed: () => logoutDevice(device.deviceId),
 | |
|                     ),
 | |
|                 ],
 | |
|               )
 | |
|               : null,
 | |
|       expandedCrossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|       children: [
 | |
|         Container(
 | |
|           decoration: BoxDecoration(
 | |
|             color: Theme.of(context).colorScheme.surfaceVariant,
 | |
|           ),
 | |
|           padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
 | |
|           child: Text('authDeviceChallenges'.tr()),
 | |
|         ),
 | |
|         for (final challenge in device.challenges)
 | |
|           ListTile(
 | |
|             minTileHeight: 48,
 | |
|             title: Text(DateFormat().format(challenge.createdAt.toLocal())),
 | |
|             subtitle: Column(
 | |
|               crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|               children: [
 | |
|                 Text(challenge.ipAddress),
 | |
|                 if (challenge.location != null)
 | |
|                   Row(
 | |
|                     spacing: 4,
 | |
|                     children:
 | |
|                         [challenge.location?.city, challenge.location?.country]
 | |
|                             .where((e) => e?.isNotEmpty ?? false)
 | |
|                             .map((e) => Text(e!))
 | |
|                             .toList(),
 | |
|                   ),
 | |
|               ],
 | |
|             ),
 | |
|           ),
 | |
|       ],
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class AccountSessionSheet extends HookConsumerWidget {
 | |
|   const AccountSessionSheet({super.key});
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     final authDevices = ref.watch(authDevicesProvider);
 | |
| 
 | |
|     void logoutDevice(String sessionId) async {
 | |
|       final confirm = await showConfirmAlert(
 | |
|         'authDeviceLogoutHint'.tr(),
 | |
|         'authDeviceLogout'.tr(),
 | |
|       );
 | |
|       if (!confirm || !context.mounted) return;
 | |
|       try {
 | |
|         final apiClient = ref.watch(apiClientProvider);
 | |
|         await apiClient.delete('/pass/accounts/me/devices/$sessionId');
 | |
|         ref.invalidate(authDevicesProvider);
 | |
|       } catch (err) {
 | |
|         showErrorAlert(err);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     void updateDeviceLabel(String sessionId) async {
 | |
|       final controller = TextEditingController();
 | |
|       final label = await showDialog<String>(
 | |
|         context: context,
 | |
|         builder:
 | |
|             (context) => AlertDialog(
 | |
|               title: Text('authDeviceLabelTitle'.tr()),
 | |
|               content: TextField(
 | |
|                 controller: controller,
 | |
|                 decoration: InputDecoration(
 | |
|                   isDense: true,
 | |
|                   border: const OutlineInputBorder(),
 | |
|                   hintText: 'authDeviceLabelHint'.tr(),
 | |
|                 ),
 | |
|                 autofocus: true,
 | |
|               ),
 | |
|               actions: [
 | |
|                 TextButton(
 | |
|                   onPressed: () => Navigator.pop(context),
 | |
|                   child: Text('cancel'.tr()),
 | |
|                 ),
 | |
|                 TextButton(
 | |
|                   onPressed: () => Navigator.pop(context, controller.text),
 | |
|                   child: Text('confirm'.tr()),
 | |
|                 ),
 | |
|               ],
 | |
|             ),
 | |
|       );
 | |
|       if (label == null || label.isEmpty || !context.mounted) return;
 | |
|       try {
 | |
|         final apiClient = ref.watch(apiClientProvider);
 | |
|         await apiClient.patch(
 | |
|           '/pass/accounts/me/devices/$sessionId/label',
 | |
|           data: jsonEncode(label),
 | |
|         );
 | |
|         ref.invalidate(authDevicesProvider);
 | |
|       } catch (err) {
 | |
|         showErrorAlert(err);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     final wideScreen = isWideScreen(context);
 | |
| 
 | |
|     return SheetScaffold(
 | |
|       titleText: 'authSessions'.tr(),
 | |
|       child: Column(
 | |
|         children: [
 | |
|           if (!wideScreen)
 | |
|             Container(
 | |
|               width: double.infinity,
 | |
|               padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
 | |
|               color: Theme.of(context).colorScheme.surfaceContainerHigh,
 | |
|               child: Row(
 | |
|                 mainAxisAlignment: MainAxisAlignment.center,
 | |
|                 crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                 spacing: 8,
 | |
|                 children: [
 | |
|                   const Icon(Symbols.info, size: 16).padding(top: 2),
 | |
|                   Flexible(
 | |
|                     child: Text(
 | |
|                       'authDeviceHint'.tr(),
 | |
|                       style: TextStyle(
 | |
|                         color: Theme.of(context).colorScheme.onSurfaceVariant,
 | |
|                       ),
 | |
|                     ),
 | |
|                   ),
 | |
|                 ],
 | |
|               ),
 | |
|             ),
 | |
|           Expanded(
 | |
|             child: authDevices.when(
 | |
|               data:
 | |
|                   (data) => ExtendedRefreshIndicator(
 | |
|                     onRefresh:
 | |
|                         () => Future.sync(
 | |
|                           () => ref.invalidate(authDevicesProvider),
 | |
|                         ),
 | |
|                     child: ListView.builder(
 | |
|                       padding: EdgeInsets.zero,
 | |
|                       itemCount: data.length,
 | |
|                       itemBuilder: (context, index) {
 | |
|                         final device = data[index];
 | |
|                         if (wideScreen) {
 | |
|                           return _DeviceListTile(
 | |
|                             device: device,
 | |
|                             updateDeviceLabel: updateDeviceLabel,
 | |
|                             logoutDevice: logoutDevice,
 | |
|                           );
 | |
|                         } else {
 | |
|                           return Dismissible(
 | |
|                             key: Key('device-${device.id}'),
 | |
|                             direction:
 | |
|                                 device.isCurrent
 | |
|                                     ? DismissDirection.startToEnd
 | |
|                                     : DismissDirection.horizontal,
 | |
|                             background: Container(
 | |
|                               color: Colors.blue,
 | |
|                               alignment: Alignment.centerLeft,
 | |
|                               padding: EdgeInsets.symmetric(horizontal: 20),
 | |
|                               child: Icon(Icons.edit, color: Colors.white),
 | |
|                             ),
 | |
|                             secondaryBackground: Container(
 | |
|                               color: Colors.red,
 | |
|                               alignment: Alignment.centerRight,
 | |
|                               padding: EdgeInsets.symmetric(horizontal: 20),
 | |
|                               child: Icon(Icons.logout, color: Colors.white),
 | |
|                             ),
 | |
|                             confirmDismiss: (direction) async {
 | |
|                               if (direction == DismissDirection.startToEnd) {
 | |
|                                 updateDeviceLabel(device.deviceId);
 | |
|                                 return false;
 | |
|                               } else {
 | |
|                                 final confirm = await showConfirmAlert(
 | |
|                                   'authDeviceLogoutHint'.tr(),
 | |
|                                   'authDeviceLogout'.tr(),
 | |
|                                 );
 | |
|                                 if (confirm && context.mounted) {
 | |
|                                   try {
 | |
|                                     showLoadingModal(context);
 | |
|                                     final apiClient = ref.watch(
 | |
|                                       apiClientProvider,
 | |
|                                     );
 | |
|                                     await apiClient.delete(
 | |
|                                       '/pass/accounts/me/devices/${device.deviceId}',
 | |
|                                     );
 | |
|                                     ref.invalidate(authDevicesProvider);
 | |
|                                   } catch (err) {
 | |
|                                     showErrorAlert(err);
 | |
|                                   } finally {
 | |
|                                     if (context.mounted) {
 | |
|                                       hideLoadingModal(context);
 | |
|                                     }
 | |
|                                   }
 | |
|                                 }
 | |
|                                 return confirm;
 | |
|                               }
 | |
|                             },
 | |
|                             child: _DeviceListTile(
 | |
|                               device: device,
 | |
|                               updateDeviceLabel: updateDeviceLabel,
 | |
|                               logoutDevice: logoutDevice,
 | |
|                             ),
 | |
|                           );
 | |
|                         }
 | |
|                       },
 | |
|                     ),
 | |
|                   ),
 | |
|               error:
 | |
|                   (err, _) => ResponseErrorWidget(
 | |
|                     error: err,
 | |
|                     onRetry: () => ref.invalidate(authDevicesProvider),
 | |
|                   ),
 | |
|               loading: () => ResponseLoadingWidget(),
 | |
|             ),
 | |
|           ),
 | |
|         ],
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 |