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/auth.dart'; import 'package:island/pods/network.dart'; import 'package:island/services/responsive.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/response.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:styled_widget/styled_widget.dart'; part 'account_session_sheet.g.dart'; @riverpod Future> authDevices(Ref ref) async { final resp = await ref.watch(apiClientProvider).get('/accounts/me/devices'); final sessionId = resp.headers.value('x-auth-session'); final data = resp.data.map((e) { final ele = SnAuthDevice.fromJson(e); return ele.copyWith(isCurrent: ele.sessions.first.id == sessionId); }).toList(); return data; } class _DeviceListTile extends StatelessWidget { final SnAuthDevice 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 ListTile( isThreeLine: true, contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), 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), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('authSessionsCount'.plural(device.sessions.length)), Text( 'lastActiveAt'.tr( args: [ DateFormat().format( device.sessions.first.lastGrantedAt.toLocal(), ), ], ), ), Text(device.sessions.first.challenge.ipAddress), if (device.isCurrent) Row( children: [ Badge( backgroundColor: Theme.of(context).colorScheme.primary, label: Text( 'authDeviceCurrent'.tr(), style: TextStyle( color: Theme.of(context).colorScheme.onPrimary, ), ), ), ], ).padding(top: 4), ], ), title: Text(device.label ?? device.sessions.first.challenge.userAgent), trailing: isWideScreen(context) ? Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: Icon(Icons.edit), tooltip: 'authDeviceEditLabel'.tr(), onPressed: () => updateDeviceLabel(device.sessions.first.id), ), if (!device.isCurrent) IconButton( icon: Icon(Icons.logout), tooltip: 'authDeviceLogout'.tr(), onPressed: () => logoutDevice(device.sessions.first.id), ), ], ) : null, ); } } 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('/accounts/me/sessions/$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( '/accounts/me/sessions/$sessionId/label', data: jsonEncode(label), ); ref.invalidate(authDevicesProvider); } catch (err) { showErrorAlert(err); } } final wideScreen = isWideScreen(context); return SheetScaffold( titleText: 'authSessions'.tr(), child: authDevices.when( data: (data) => RefreshIndicator( 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.sessions.first.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.sessions.first.id); return false; } else { final confirm = await showConfirmAlert( 'authDeviceLogoutHint'.tr(), 'authDeviceLogout'.tr(), ); if (confirm && context.mounted) { logoutDevice(device.sessions.first.id); } return false; // Don't dismiss } }, child: _DeviceListTile( device: device, updateDeviceLabel: updateDeviceLabel, logoutDevice: logoutDevice, ), ); } }, ), ), error: (err, _) => ResponseErrorWidget( error: err, onRetry: () => ref.invalidate(authDevicesProvider), ), loading: () => ResponseLoadingWidget(), ), ); } }