diff --git a/lib/config/bloc_config.dart b/lib/config/bloc_config.dart index ecb54025..55fca0fe 100644 --- a/lib/config/bloc_config.dart +++ b/lib/config/bloc_config.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:selfprivacy/logic/bloc/backups/backups_bloc.dart'; import 'package:selfprivacy/logic/bloc/connection_status/connection_status_bloc.dart'; +import 'package:selfprivacy/logic/bloc/recovery_key/recovery_key_bloc.dart'; import 'package:selfprivacy/logic/bloc/server_jobs/server_jobs_bloc.dart'; import 'package:selfprivacy/logic/bloc/services/services_bloc.dart'; import 'package:selfprivacy/logic/bloc/volumes/volumes_bloc.dart'; @@ -9,7 +10,6 @@ import 'package:selfprivacy/logic/cubit/app_settings/app_settings_cubit.dart'; import 'package:selfprivacy/logic/cubit/client_jobs/client_jobs_cubit.dart'; import 'package:selfprivacy/logic/cubit/devices/devices_cubit.dart'; import 'package:selfprivacy/logic/cubit/dns_records/dns_records_cubit.dart'; -import 'package:selfprivacy/logic/cubit/recovery_key/recovery_key_cubit.dart'; import 'package:selfprivacy/logic/cubit/server_detailed_info/server_detailed_info_cubit.dart'; import 'package:selfprivacy/logic/cubit/server_installation/server_installation_cubit.dart'; import 'package:selfprivacy/logic/cubit/support_system/support_system_cubit.dart'; @@ -31,7 +31,7 @@ class BlocAndProviderConfigState extends State { late final ServicesBloc servicesBloc; late final BackupsBloc backupsBloc; late final DnsRecordsCubit dnsRecordsCubit; - late final RecoveryKeyCubit recoveryKeyCubit; + late final RecoveryKeyBloc recoveryKeyBloc; late final ApiDevicesCubit apiDevicesCubit; late final ServerJobsBloc serverJobsBloc; late final ConnectionStatusBloc connectionStatusBloc; @@ -47,7 +47,7 @@ class BlocAndProviderConfigState extends State { servicesBloc = ServicesBloc(); backupsBloc = BackupsBloc(); dnsRecordsCubit = DnsRecordsCubit(); - recoveryKeyCubit = RecoveryKeyCubit(); + recoveryKeyBloc = RecoveryKeyBloc(); apiDevicesCubit = ApiDevicesCubit(); serverJobsBloc = ServerJobsBloc(); connectionStatusBloc = ConnectionStatusBloc(); @@ -90,7 +90,7 @@ class BlocAndProviderConfigState extends State { create: (final _) => dnsRecordsCubit, ), BlocProvider( - create: (final _) => recoveryKeyCubit, + create: (final _) => recoveryKeyBloc, ), BlocProvider( create: (final _) => apiDevicesCubit, diff --git a/lib/logic/bloc/recovery_key/recovery_key_bloc.dart b/lib/logic/bloc/recovery_key/recovery_key_bloc.dart new file mode 100644 index 00000000..905d6594 --- /dev/null +++ b/lib/logic/bloc/recovery_key/recovery_key_bloc.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +import 'package:bloc_concurrency/bloc_concurrency.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:selfprivacy/config/get_it_config.dart'; +import 'package:selfprivacy/logic/api_maps/generic_result.dart'; +import 'package:selfprivacy/logic/models/json/recovery_token_status.dart'; + +part 'recovery_key_event.dart'; +part 'recovery_key_state.dart'; + +class RecoveryKeyBloc extends Bloc { + RecoveryKeyBloc() : super(RecoveryKeyInitial()) { + on( + _mapRecoveryKeyStatusChangedToState, + transformer: sequential(), + ); + on( + _mapCreateNewRecoveryKeyToState, + transformer: sequential(), + ); + on( + _mapRecoveryKeyStatusRefreshToState, + transformer: sequential(), + ); + on( + _mapRecoveryKeyStatusRefreshToState, + transformer: droppable(), + ); + + final apiConnectionRepository = getIt(); + _apiDataSubscription = apiConnectionRepository.dataStream.listen( + (final ApiData apiData) { + add( + RecoveryKeyStatusChanged(apiData.recoveryKeyStatus.data), + ); + }, + ); + } + + StreamSubscription? _apiDataSubscription; + + Future _mapRecoveryKeyStatusChangedToState( + final RecoveryKeyStatusChanged event, + final Emitter emit, + ) async { + if (state is RecoveryKeyCreating) { + return; + } + if (event.recoveryKeyStatus == null) { + emit(RecoveryKeyError()); + return; + } + emit(RecoveryKeyLoaded(keyStatus: event.recoveryKeyStatus)); + } + + Future _mapCreateNewRecoveryKeyToState( + final CreateNewRecoveryKey event, + final Emitter emit, + ) async { + emit(RecoveryKeyCreating()); + final GenericResult response = + await getIt().api.generateRecoveryToken( + event.expirationDate, + event.numberOfUses, + ); + if (response.success) { + emit(RecoveryKeyCreating(recoveryKey: response.data)); + } else { + emit(RecoveryKeyCreating(error: response.message ?? 'Unknown error')); + } + } + + Future _mapRecoveryKeyStatusRefreshToState( + final RecoveryKeyEvent event, + final Emitter emit, + ) async { + emit(RecoveryKeyRefreshing(keyStatus: state._status)); + getIt().apiData.recoveryKeyStatus.invalidate(); + await getIt().reload(null); + } + + @override + void onChange(final Change change) { + super.onChange(change); + } + + @override + Future close() { + _apiDataSubscription?.cancel(); + return super.close(); + } +} diff --git a/lib/logic/bloc/recovery_key/recovery_key_event.dart b/lib/logic/bloc/recovery_key/recovery_key_event.dart new file mode 100644 index 00000000..80104ada --- /dev/null +++ b/lib/logic/bloc/recovery_key/recovery_key_event.dart @@ -0,0 +1,41 @@ +part of 'recovery_key_bloc.dart'; + +sealed class RecoveryKeyEvent extends Equatable { + const RecoveryKeyEvent(); +} + +class RecoveryKeyStatusChanged extends RecoveryKeyEvent { + const RecoveryKeyStatusChanged(this.recoveryKeyStatus); + + final RecoveryKeyStatus? recoveryKeyStatus; + + @override + List get props => [recoveryKeyStatus]; +} + +class CreateNewRecoveryKey extends RecoveryKeyEvent { + const CreateNewRecoveryKey({ + this.expirationDate, + this.numberOfUses, + }); + + final DateTime? expirationDate; + final int? numberOfUses; + + @override + List get props => [expirationDate, numberOfUses]; +} + +class ConsumedNewRecoveryKey extends RecoveryKeyEvent { + const ConsumedNewRecoveryKey(); + + @override + List get props => []; +} + +class RecoveryKeyStatusRefresh extends RecoveryKeyEvent { + const RecoveryKeyStatusRefresh(); + + @override + List get props => []; +} diff --git a/lib/logic/bloc/recovery_key/recovery_key_state.dart b/lib/logic/bloc/recovery_key/recovery_key_state.dart new file mode 100644 index 00000000..1e22ac9f --- /dev/null +++ b/lib/logic/bloc/recovery_key/recovery_key_state.dart @@ -0,0 +1,67 @@ +part of 'recovery_key_bloc.dart'; + +sealed class RecoveryKeyState extends Equatable { + RecoveryKeyState({ + required final RecoveryKeyStatus? keyStatus, + }) : _hashCode = keyStatus.hashCode; + + final int _hashCode; + + RecoveryKeyStatus get _status => + getIt().apiData.recoveryKeyStatus.data ?? + const RecoveryKeyStatus(exists: false, valid: false); + + bool get exists => _status.exists; + bool get isValid => _status.valid; + DateTime? get generatedAt => _status.date; + DateTime? get expiresAt => _status.expiration; + int? get usesLeft => _status.usesLeft; + + bool get isInvalidBecauseExpired => + _status.expiration != null && + _status.expiration!.isBefore(DateTime.now()); + + bool get isInvalidBecauseUsed => + _status.usesLeft != null && _status.usesLeft == 0; +} + +class RecoveryKeyInitial extends RecoveryKeyState { + RecoveryKeyInitial() + : super(keyStatus: const RecoveryKeyStatus(exists: false, valid: false)); + + @override + List get props => [_hashCode]; +} + +class RecoveryKeyRefreshing extends RecoveryKeyState { + RecoveryKeyRefreshing({required super.keyStatus}); + + @override + List get props => [_hashCode]; +} + +class RecoveryKeyLoaded extends RecoveryKeyState { + RecoveryKeyLoaded({required super.keyStatus}); + + @override + List get props => [_hashCode]; +} + +class RecoveryKeyError extends RecoveryKeyState { + RecoveryKeyError() + : super(keyStatus: const RecoveryKeyStatus(exists: false, valid: false)); + + @override + List get props => [_hashCode]; +} + +class RecoveryKeyCreating extends RecoveryKeyState { + RecoveryKeyCreating({this.recoveryKey, this.error}) + : super(keyStatus: const RecoveryKeyStatus(exists: false, valid: false)); + + final String? recoveryKey; + final String? error; + + @override + List get props => [_hashCode, recoveryKey, error]; +} diff --git a/lib/logic/cubit/recovery_key/recovery_key_cubit.dart b/lib/logic/cubit/recovery_key/recovery_key_cubit.dart deleted file mode 100644 index 42811b85..00000000 --- a/lib/logic/cubit/recovery_key/recovery_key_cubit.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'dart:async'; - -import 'package:selfprivacy/logic/api_maps/graphql_maps/server_api/server_api.dart'; -import 'package:selfprivacy/logic/common_enum/common_enum.dart'; -import 'package:selfprivacy/logic/cubit/server_connection_dependent/server_connection_dependent_cubit.dart'; -import 'package:selfprivacy/logic/models/json/recovery_token_status.dart'; - -part 'recovery_key_state.dart'; - -class RecoveryKeyCubit - extends ServerConnectionDependentCubit { - RecoveryKeyCubit() : super(const RecoveryKeyState.initial()); - - final ServerApi api = ServerApi(); - - @override - void load() async { - // if (serverInstallationCubit.state is ServerInstallationFinished) { - final RecoveryKeyStatus? status = await _getRecoveryKeyStatus(); - if (status == null) { - emit(state.copyWith(loadingStatus: LoadingStatus.error)); - } else { - emit( - state.copyWith( - status: status, - loadingStatus: LoadingStatus.success, - ), - ); - } - // } else { - // emit(state.copyWith(loadingStatus: LoadingStatus.uninitialized)); - // } - } - - Future _getRecoveryKeyStatus() async { - final GenericResult response = - await api.getRecoveryTokenStatus(); - if (response.success) { - return response.data; - } else { - return null; - } - } - - Future refresh() async { - emit(state.copyWith(loadingStatus: LoadingStatus.refreshing)); - final RecoveryKeyStatus? status = await _getRecoveryKeyStatus(); - if (status == null) { - emit(state.copyWith(loadingStatus: LoadingStatus.error)); - } else { - emit( - state.copyWith(status: status, loadingStatus: LoadingStatus.success), - ); - } - } - - Future generateRecoveryKey({ - final DateTime? expirationDate, - final int? numberOfUses, - }) async { - final GenericResult response = - await api.generateRecoveryToken(expirationDate, numberOfUses); - if (response.success) { - unawaited(refresh()); - return response.data; - } else { - throw GenerationError(response.message ?? 'Unknown error'); - } - } - - @override - void clear() { - emit(state.copyWith(loadingStatus: LoadingStatus.uninitialized)); - } -} - -class GenerationError extends Error { - GenerationError(this.message); - final String message; -} diff --git a/lib/logic/cubit/recovery_key/recovery_key_state.dart b/lib/logic/cubit/recovery_key/recovery_key_state.dart deleted file mode 100644 index b35ae9a3..00000000 --- a/lib/logic/cubit/recovery_key/recovery_key_state.dart +++ /dev/null @@ -1,39 +0,0 @@ -part of 'recovery_key_cubit.dart'; - -class RecoveryKeyState extends ServerInstallationDependendState { - const RecoveryKeyState(this._status, this.loadingStatus); - - const RecoveryKeyState.initial() - : this( - const RecoveryKeyStatus(exists: false, valid: false), - LoadingStatus.refreshing, - ); - - final RecoveryKeyStatus _status; - final LoadingStatus loadingStatus; - - bool get exists => _status.exists; - bool get isValid => _status.valid; - DateTime? get generatedAt => _status.date; - DateTime? get expiresAt => _status.expiration; - int? get usesLeft => _status.usesLeft; - - bool get isInvalidBecauseExpired => - _status.expiration != null && - _status.expiration!.isBefore(DateTime.now()); - - bool get isInvalidBecauseUsed => - _status.usesLeft != null && _status.usesLeft == 0; - - @override - List get props => [_status, loadingStatus]; - - RecoveryKeyState copyWith({ - final RecoveryKeyStatus? status, - final LoadingStatus? loadingStatus, - }) => - RecoveryKeyState( - status ?? _status, - loadingStatus ?? this.loadingStatus, - ); -} diff --git a/lib/logic/get_it/api_connection_repository.dart b/lib/logic/get_it/api_connection_repository.dart index 6f0210ee..6ed4ba79 100644 --- a/lib/logic/get_it/api_connection_repository.dart +++ b/lib/logic/get_it/api_connection_repository.dart @@ -8,6 +8,7 @@ import 'package:selfprivacy/logic/api_maps/graphql_maps/server_api/server_api.da import 'package:selfprivacy/logic/models/backup.dart'; import 'package:selfprivacy/logic/models/hive/server_details.dart'; import 'package:selfprivacy/logic/models/hive/server_domain.dart'; +import 'package:selfprivacy/logic/models/json/recovery_token_status.dart'; import 'package:selfprivacy/logic/models/json/server_disk_volume.dart'; import 'package:selfprivacy/logic/models/json/server_job.dart'; import 'package:selfprivacy/logic/models/service.dart'; @@ -101,6 +102,8 @@ class ApiConnectionRepository { _apiData.backups.data = await _apiData.backups.fetchData(); _apiData.services.data = await _apiData.services.fetchData(); _apiData.volumes.data = await _apiData.volumes.fetchData(); + _apiData.recoveryKeyStatus.data = + await _apiData.recoveryKeyStatus.fetchData(); _dataStream.add(_apiData); connectionStatus = ConnectionStatus.connected; @@ -140,6 +143,8 @@ class ApiConnectionRepository { .refetchData(version, () => _dataStream.add(_apiData)); await _apiData.volumes .refetchData(version, () => _dataStream.add(_apiData)); + await _apiData.recoveryKeyStatus + .refetchData(version, () => _dataStream.add(_apiData)); } void emitData() { @@ -159,10 +164,12 @@ class ApiData { backupConfig = ApiDataElement( fetchData: () async => api.getBackupsConfiguration(), requiredApiVersion: '>=2.4.2', + ttl: 120, ), backups = ApiDataElement>( fetchData: () async => api.getBackups(), requiredApiVersion: '>=2.4.2', + ttl: 120, ), services = ApiDataElement>( fetchData: () async => api.getAllServices(), @@ -170,6 +177,10 @@ class ApiData { ), volumes = ApiDataElement>( fetchData: () async => api.getServerDiskVolumes(), + ), + recoveryKeyStatus = ApiDataElement( + fetchData: () async => (await api.getRecoveryTokenStatus()).data, + ttl: 300, ); ApiDataElement> serverJobs; @@ -178,6 +189,7 @@ class ApiData { ApiDataElement> backups; ApiDataElement> services; ApiDataElement> volumes; + ApiDataElement recoveryKeyStatus; } enum ConnectionStatus { diff --git a/lib/ui/pages/more/app_settings/developer_settings.dart b/lib/ui/pages/more/app_settings/developer_settings.dart index 015aa3d2..8acf16a4 100644 --- a/lib/ui/pages/more/app_settings/developer_settings.dart +++ b/lib/ui/pages/more/app_settings/developer_settings.dart @@ -4,8 +4,6 @@ import 'package:flutter/material.dart'; import 'package:selfprivacy/config/get_it_config.dart'; import 'package:selfprivacy/logic/api_maps/tls_options.dart'; import 'package:selfprivacy/logic/cubit/app_settings/app_settings_cubit.dart'; -import 'package:selfprivacy/logic/cubit/devices/devices_cubit.dart'; -import 'package:selfprivacy/logic/cubit/recovery_key/recovery_key_cubit.dart'; import 'package:selfprivacy/ui/layouts/brand_hero_screen.dart'; @RoutePage() @@ -89,18 +87,6 @@ class _DeveloperSettingsPageState extends State { ), ), ), - ListTile( - title: const Text('ApiDevicesCubit'), - subtitle: Text( - context.watch().state.status.toString(), - ), - ), - ListTile( - title: const Text('RecoveryKeyCubit'), - subtitle: Text( - context.watch().state.loadingStatus.toString(), - ), - ), ListTile( title: const Text('ApiConnectionRepository status'), subtitle: Text( diff --git a/lib/ui/pages/recovery_key/recovery_key.dart b/lib/ui/pages/recovery_key/recovery_key.dart index ff449377..a8a47593 100644 --- a/lib/ui/pages/recovery_key/recovery_key.dart +++ b/lib/ui/pages/recovery_key/recovery_key.dart @@ -2,9 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:selfprivacy/config/get_it_config.dart'; -import 'package:selfprivacy/logic/common_enum/common_enum.dart'; -import 'package:selfprivacy/logic/cubit/recovery_key/recovery_key_cubit.dart'; +import 'package:selfprivacy/logic/bloc/recovery_key/recovery_key_bloc.dart'; import 'package:selfprivacy/logic/cubit/server_installation/server_installation_cubit.dart'; import 'package:selfprivacy/ui/components/buttons/brand_button.dart'; import 'package:selfprivacy/ui/components/cards/filled_card.dart'; @@ -21,34 +19,29 @@ class RecoveryKeyPage extends StatefulWidget { } class _RecoveryKeyPageState extends State { - @override - void initState() { - super.initState(); - context.read().load(); - } - @override Widget build(final BuildContext context) { - final RecoveryKeyState keyStatus = context.watch().state; + final RecoveryKeyState keyStatus = context.watch().state; final List widgets; String? subtitle = keyStatus.exists ? null : 'recovery_key.key_main_description'.tr(); - switch (keyStatus.loadingStatus) { - case LoadingStatus.refreshing: + switch (keyStatus) { + case RecoveryKeyRefreshing(): subtitle = 'recovery_key.key_synchronizing'.tr(); widgets = [ const Center(child: CircularProgressIndicator()), ]; break; - case LoadingStatus.success: + case RecoveryKeyLoaded(): widgets = [ const RecoveryKeyContent(), ]; break; - case LoadingStatus.uninitialized: - case LoadingStatus.error: + case RecoveryKeyInitial(): + case RecoveryKeyError(): + case RecoveryKeyCreating(): subtitle = 'recovery_key.key_connection_error'.tr(); widgets = [ const Icon(Icons.sentiment_dissatisfied_outlined), @@ -58,7 +51,7 @@ class _RecoveryKeyPageState extends State { return RefreshIndicator( onRefresh: () async { - context.read().load(); + context.read().add(const RecoveryKeyStatusRefresh()); }, child: BrandHeroScreen( heroTitle: 'recovery_key.key_main_header'.tr(), @@ -83,7 +76,7 @@ class _RecoveryKeyContentState extends State { @override Widget build(final BuildContext context) { - final RecoveryKeyState keyStatus = context.watch().state; + final RecoveryKeyState keyStatus = context.watch().state; return Column( children: [ @@ -241,34 +234,24 @@ class _RecoveryKeyConfigurationState extends State { setState(() { _isLoading = true; }); - try { - final String token = - await context.read().generateRecoveryKey( - numberOfUses: _isAmountToggled - ? int.tryParse(_amountController.text) - : null, - expirationDate: _isExpirationToggled ? _selectedDate : null, - ); - if (!mounted) { - return; - } - setState(() { - _isLoading = false; - }); - await Navigator.of(context).push( - materialRoute( - RecoveryKeyReceiving(recoveryKey: token), // TO DO - ), - ); - } on GenerationError catch (e) { - setState(() { - _isLoading = false; - }); - getIt().showSnackBar( - 'recovery_key.generation_error'.tr(args: [e.message]), - ); + context.read().add( + CreateNewRecoveryKey( + expirationDate: _isExpirationToggled ? _selectedDate : null, + numberOfUses: + _isAmountToggled ? int.tryParse(_amountController.text) : null, + ), + ); + if (!mounted) { return; } + setState(() { + _isLoading = false; + }); + await Navigator.of(context).push( + materialRoute( + const RecoveryKeyReceiving(), + ), + ); } void _updateErrorStatuses() { diff --git a/lib/ui/pages/recovery_key/recovery_key_receiving.dart b/lib/ui/pages/recovery_key/recovery_key_receiving.dart index 334ba308..8167ac1e 100644 --- a/lib/ui/pages/recovery_key/recovery_key_receiving.dart +++ b/lib/ui/pages/recovery_key/recovery_key_receiving.dart @@ -1,23 +1,36 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:selfprivacy/logic/bloc/recovery_key/recovery_key_bloc.dart'; import 'package:selfprivacy/ui/components/buttons/brand_button.dart'; import 'package:selfprivacy/ui/components/info_box/info_box.dart'; import 'package:selfprivacy/ui/layouts/brand_hero_screen.dart'; class RecoveryKeyReceiving extends StatelessWidget { - const RecoveryKeyReceiving({required this.recoveryKey, super.key}); - - final String recoveryKey; + const RecoveryKeyReceiving({super.key}); @override - Widget build(final BuildContext context) => BrandHeroScreen( - heroTitle: 'recovery_key.key_main_header'.tr(), - heroSubtitle: 'recovery_key.key_receiving_description'.tr(), - hasBackButton: true, - hasFlashButton: false, - children: [ - const Divider(), - const SizedBox(height: 16), + Widget build(final BuildContext context) { + final recoveryKeyState = context.watch().state; + + final String? recoveryKey = recoveryKeyState is RecoveryKeyCreating + ? recoveryKeyState.recoveryKey + : null; + + final String? error = + recoveryKeyState is RecoveryKeyCreating ? recoveryKeyState.error : null; + + return BrandHeroScreen( + heroTitle: 'recovery_key.key_main_header'.tr(), + heroSubtitle: 'recovery_key.key_receiving_description'.tr(), + hasBackButton: true, + hasFlashButton: false, + children: [ + const Divider(), + const SizedBox(height: 16), + if (recoveryKey == null && error == null) + const Center(child: CircularProgressIndicator()), + if (recoveryKey != null) Text( recoveryKey, style: Theme.of(context).textTheme.bodyLarge!.copyWith( @@ -26,19 +39,31 @@ class RecoveryKeyReceiving extends StatelessWidget { ), textAlign: TextAlign.center, ), - const SizedBox(height: 16), - const Divider(), - const SizedBox(height: 16), - InfoBox( - text: 'recovery_key.key_receiving_info'.tr(), + if (error != null) + Text( + 'recovery_key.generation_error'.tr(args: [error]), + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + fontSize: 24, + fontFamily: 'RobotoMono', + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, ), - const SizedBox(height: 16), - BrandButton.filled( - child: Text('recovery_key.key_receiving_done'.tr()), - onPressed: () { - Navigator.of(context).popUntil((final route) => route.isFirst); - }, - ), - ], - ); + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 16), + InfoBox( + text: 'recovery_key.key_receiving_info'.tr(), + ), + const SizedBox(height: 16), + BrandButton.filled( + child: Text('recovery_key.key_receiving_done'.tr()), + onPressed: () { + context.read().add(const ConsumedNewRecoveryKey()); + Navigator.of(context).popUntil((final route) => route.isFirst); + }, + ), + ], + ); + } }