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> authDevices(Ref ref) async { final resp = await ref .watch(apiClientProvider) .get('/id/accounts/me/devices'); final currentId = await getUdid(); final data = resp.data.map((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('/id/accounts/me/devices/$sessionId'); ref.invalidate(authDevicesProvider); } catch (err) { showErrorAlert(err); } } void updateDeviceLabel(String sessionId) async { final controller = TextEditingController(); final label = await showDialog( 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( '/id/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( '/id/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(), ), ), ], ), ); } }