jld3103
1 year ago
20 changed files with 891 additions and 381 deletions
@ -1,120 +0,0 @@
|
||||
import 'dart:async'; |
||||
|
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:meta/meta.dart'; |
||||
import 'package:neon/src/bloc/bloc.dart'; |
||||
import 'package:neon/src/models/account.dart'; |
||||
import 'package:nextcloud/nextcloud.dart'; |
||||
import 'package:package_info_plus/package_info_plus.dart'; |
||||
import 'package:rxdart/rxdart.dart'; |
||||
|
||||
abstract class LoginBlocEvents { |
||||
void setServerURL(final String? url); |
||||
} |
||||
|
||||
abstract class LoginBlocStates { |
||||
BehaviorSubject<String?> get serverURL; |
||||
|
||||
BehaviorSubject<ServerConnectionState?> get serverConnectionState; |
||||
|
||||
BehaviorSubject<CoreLoginFlowInit?> get loginFlowInit; |
||||
|
||||
BehaviorSubject<CoreLoginFlowResult?> get loginFlowResult; |
||||
} |
||||
|
||||
@internal |
||||
class LoginBloc extends InteractiveBloc implements LoginBlocEvents, LoginBlocStates { |
||||
LoginBloc(this._packageInfo); |
||||
|
||||
final PackageInfo _packageInfo; |
||||
|
||||
Timer? _pollTimer; |
||||
|
||||
@override |
||||
void dispose() { |
||||
_cancelPollTimer(); |
||||
unawaited(serverURL.close()); |
||||
unawaited(serverConnectionState.close()); |
||||
unawaited(loginFlowInit.close()); |
||||
unawaited(loginFlowResult.close()); |
||||
} |
||||
|
||||
@override |
||||
BehaviorSubject<CoreLoginFlowInit?> loginFlowInit = BehaviorSubject<CoreLoginFlowInit?>.seeded(null); |
||||
|
||||
@override |
||||
BehaviorSubject<CoreLoginFlowResult?> loginFlowResult = BehaviorSubject<CoreLoginFlowResult?>.seeded(null); |
||||
|
||||
@override |
||||
BehaviorSubject<ServerConnectionState?> serverConnectionState = BehaviorSubject<ServerConnectionState?>.seeded(null); |
||||
|
||||
@override |
||||
BehaviorSubject<String?> serverURL = BehaviorSubject<String?>.seeded(null); |
||||
|
||||
@override |
||||
Future refresh() async { |
||||
await _setServerURL(serverURL.valueOrNull); |
||||
} |
||||
|
||||
@override |
||||
void setServerURL(final String? url) { |
||||
unawaited(_setServerURL(url)); |
||||
} |
||||
|
||||
Future _setServerURL(final String? url) async { |
||||
serverURL.add(url); |
||||
loginFlowInit.add(null); |
||||
loginFlowResult.add(null); |
||||
serverConnectionState.add(url != null ? ServerConnectionState.loading : null); |
||||
|
||||
if (url != null) { |
||||
try { |
||||
final client = NextcloudClient( |
||||
url, |
||||
userAgentOverride: userAgent(_packageInfo), |
||||
); |
||||
|
||||
final status = await client.core.getStatus(); |
||||
if (status.maintenance) { |
||||
serverConnectionState.add(ServerConnectionState.maintenanceMode); |
||||
return; |
||||
} |
||||
|
||||
serverConnectionState.add(ServerConnectionState.success); |
||||
|
||||
final init = await client.core.initLoginFlow(); |
||||
loginFlowInit.add(init); |
||||
|
||||
_cancelPollTimer(); |
||||
_pollTimer = Timer.periodic(const Duration(seconds: 2), (final _) async { |
||||
try { |
||||
final result = await client.core.getLoginFlowResult(token: init.poll.token); |
||||
_cancelPollTimer(); |
||||
loginFlowResult.add(result); |
||||
} catch (e, s) { |
||||
debugPrint(e.toString()); |
||||
debugPrint(s.toString()); |
||||
} |
||||
}); |
||||
} catch (e, s) { |
||||
debugPrint(e.toString()); |
||||
debugPrint(s.toString()); |
||||
serverConnectionState.add(ServerConnectionState.unreachable); |
||||
} |
||||
} |
||||
} |
||||
|
||||
void _cancelPollTimer() { |
||||
if (_pollTimer != null) { |
||||
_pollTimer!.cancel(); |
||||
_pollTimer = null; |
||||
} |
||||
} |
||||
} |
||||
|
||||
enum ServerConnectionState { |
||||
loading, |
||||
unreachable, |
||||
maintenanceMode, |
||||
success, |
||||
} |
@ -0,0 +1,71 @@
|
||||
import 'dart:async'; |
||||
|
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:neon/src/bloc/bloc.dart'; |
||||
import 'package:neon/src/bloc/result.dart'; |
||||
import 'package:neon/src/models/account.dart'; |
||||
import 'package:nextcloud/nextcloud.dart'; |
||||
import 'package:package_info_plus/package_info_plus.dart'; |
||||
import 'package:rxdart/rxdart.dart'; |
||||
|
||||
abstract interface class LoginCheckAccountBlocEvents {} |
||||
|
||||
abstract interface class LoginCheckAccountBlocStates { |
||||
/// Contains the account for the user |
||||
BehaviorSubject<Result<Account>> get state; |
||||
} |
||||
|
||||
class LoginCheckAccountBloc extends InteractiveBloc |
||||
implements LoginCheckAccountBlocEvents, LoginCheckAccountBlocStates { |
||||
LoginCheckAccountBloc( |
||||
this._packageInfo, |
||||
this.serverURL, |
||||
this.loginName, |
||||
this.password, |
||||
) { |
||||
unawaited(refresh()); |
||||
} |
||||
|
||||
final PackageInfo _packageInfo; |
||||
final String serverURL; |
||||
final String loginName; |
||||
final String password; |
||||
|
||||
@override |
||||
void dispose() { |
||||
unawaited(state.close()); |
||||
} |
||||
|
||||
@override |
||||
BehaviorSubject<Result<Account>> state = BehaviorSubject(); |
||||
|
||||
@override |
||||
Future refresh() async { |
||||
state.add(Result.loading()); |
||||
|
||||
try { |
||||
final client = NextcloudClient( |
||||
serverURL, |
||||
loginName: loginName, |
||||
password: password, |
||||
userAgentOverride: userAgent(_packageInfo), |
||||
); |
||||
|
||||
final response = await client.provisioningApi.getCurrentUser(); |
||||
|
||||
final account = Account( |
||||
serverURL: serverURL, |
||||
loginName: loginName, |
||||
username: response.ocs.data.id, |
||||
password: password, |
||||
userAgent: userAgent(_packageInfo), |
||||
); |
||||
|
||||
state.add(Result.success(account)); |
||||
} catch (e, s) { |
||||
debugPrint(e.toString()); |
||||
debugPrint(s.toString()); |
||||
state.add(Result.error(e)); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,56 @@
|
||||
import 'dart:async'; |
||||
|
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:neon/src/bloc/bloc.dart'; |
||||
import 'package:neon/src/bloc/result.dart'; |
||||
import 'package:neon/src/models/account.dart'; |
||||
import 'package:nextcloud/nextcloud.dart'; |
||||
import 'package:package_info_plus/package_info_plus.dart'; |
||||
import 'package:rxdart/rxdart.dart'; |
||||
|
||||
abstract interface class LoginCheckServerStatusBlocEvents {} |
||||
|
||||
abstract interface class LoginCheckServerStatusBlocStates { |
||||
/// Contains the current server connection state |
||||
BehaviorSubject<Result<CoreServerStatus>> get state; |
||||
} |
||||
|
||||
class LoginCheckServerStatusBloc extends InteractiveBloc |
||||
implements LoginCheckServerStatusBlocEvents, LoginCheckServerStatusBlocStates { |
||||
LoginCheckServerStatusBloc( |
||||
this._packageInfo, |
||||
this.serverURL, |
||||
) { |
||||
unawaited(refresh()); |
||||
} |
||||
|
||||
final PackageInfo _packageInfo; |
||||
final String serverURL; |
||||
|
||||
@override |
||||
void dispose() { |
||||
unawaited(state.close()); |
||||
} |
||||
|
||||
@override |
||||
BehaviorSubject<Result<CoreServerStatus>> state = BehaviorSubject(); |
||||
|
||||
@override |
||||
Future refresh() async { |
||||
state.add(Result.loading()); |
||||
|
||||
try { |
||||
final client = NextcloudClient( |
||||
serverURL, |
||||
userAgentOverride: userAgent(_packageInfo), |
||||
); |
||||
|
||||
final status = await client.core.getStatus(); |
||||
state.add(Result.success(status)); |
||||
} catch (e, s) { |
||||
debugPrint(e.toString()); |
||||
debugPrint(s.toString()); |
||||
state.add(Result.error(e)); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,82 @@
|
||||
import 'dart:async'; |
||||
|
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:neon/src/bloc/bloc.dart'; |
||||
import 'package:neon/src/bloc/result.dart'; |
||||
import 'package:neon/src/models/account.dart'; |
||||
import 'package:nextcloud/nextcloud.dart'; |
||||
import 'package:package_info_plus/package_info_plus.dart'; |
||||
import 'package:rxdart/rxdart.dart'; |
||||
|
||||
abstract class LoginFlowBlocEvents {} |
||||
|
||||
abstract class LoginFlowBlocStates { |
||||
BehaviorSubject<Result<CoreLoginFlowInit>> get init; |
||||
|
||||
Stream<CoreLoginFlowResult> get result; |
||||
} |
||||
|
||||
class LoginFlowBloc extends InteractiveBloc implements LoginFlowBlocEvents, LoginFlowBlocStates { |
||||
LoginFlowBloc( |
||||
this._packageInfo, |
||||
this.serverURL, |
||||
) { |
||||
unawaited(refresh()); |
||||
} |
||||
|
||||
final PackageInfo _packageInfo; |
||||
final String serverURL; |
||||
late final _client = NextcloudClient( |
||||
serverURL, |
||||
userAgentOverride: userAgent(_packageInfo), |
||||
); |
||||
final _resultController = StreamController<CoreLoginFlowResult>(); |
||||
|
||||
Timer? _pollTimer; |
||||
|
||||
@override |
||||
void dispose() { |
||||
_cancelPollTimer(); |
||||
unawaited(init.close()); |
||||
unawaited(_resultController.close()); |
||||
} |
||||
|
||||
@override |
||||
BehaviorSubject<Result<CoreLoginFlowInit>> init = BehaviorSubject<Result<CoreLoginFlowInit>>(); |
||||
|
||||
@override |
||||
late Stream<CoreLoginFlowResult> result = _resultController.stream.asBroadcastStream(); |
||||
|
||||
@override |
||||
Future refresh() async { |
||||
try { |
||||
init.add(Result.loading()); |
||||
|
||||
final initResponse = await _client.core.initLoginFlow(); |
||||
init.add(Result.success(initResponse)); |
||||
|
||||
_cancelPollTimer(); |
||||
_pollTimer = Timer.periodic(const Duration(seconds: 1), (final _) async { |
||||
try { |
||||
final resultResponse = await _client.core.getLoginFlowResult(token: initResponse.poll.token); |
||||
_cancelPollTimer(); |
||||
_resultController.add(resultResponse); |
||||
} catch (e, s) { |
||||
debugPrint(e.toString()); |
||||
debugPrint(s.toString()); |
||||
} |
||||
}); |
||||
} catch (e, s) { |
||||
debugPrint(e.toString()); |
||||
debugPrint(s.toString()); |
||||
init.add(Result.error(e)); |
||||
} |
||||
} |
||||
|
||||
void _cancelPollTimer() { |
||||
if (_pollTimer != null) { |
||||
_pollTimer!.cancel(); |
||||
_pollTimer = null; |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,112 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:neon/l10n/localizations.dart'; |
||||
import 'package:neon/src/bloc/result.dart'; |
||||
import 'package:neon/src/bloc/result_builder.dart'; |
||||
import 'package:neon/src/blocs/accounts.dart'; |
||||
import 'package:neon/src/blocs/login_check_account.dart'; |
||||
import 'package:neon/src/models/account.dart'; |
||||
import 'package:neon/src/router.dart'; |
||||
import 'package:neon/src/widgets/account_tile.dart'; |
||||
import 'package:neon/src/widgets/exception.dart'; |
||||
import 'package:neon/src/widgets/linear_progress_indicator.dart'; |
||||
import 'package:neon/src/widgets/validation_tile.dart'; |
||||
import 'package:package_info_plus/package_info_plus.dart'; |
||||
import 'package:provider/provider.dart'; |
||||
|
||||
class LoginCheckAccountPage extends StatefulWidget { |
||||
const LoginCheckAccountPage({ |
||||
required this.serverURL, |
||||
required this.loginName, |
||||
required this.password, |
||||
super.key, |
||||
}); |
||||
|
||||
final String serverURL; |
||||
final String loginName; |
||||
final String password; |
||||
|
||||
@override |
||||
State<LoginCheckAccountPage> createState() => _LoginCheckAccountPageState(); |
||||
} |
||||
|
||||
class _LoginCheckAccountPageState extends State<LoginCheckAccountPage> { |
||||
late final LoginCheckAccountBloc bloc; |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
|
||||
bloc = LoginCheckAccountBloc( |
||||
Provider.of<PackageInfo>(context, listen: false), |
||||
widget.serverURL, |
||||
widget.loginName, |
||||
widget.password, |
||||
); |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
bloc.dispose(); |
||||
super.dispose(); |
||||
} |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) => Scaffold( |
||||
appBar: AppBar(), |
||||
body: Center( |
||||
child: Padding( |
||||
padding: const EdgeInsets.all(10), |
||||
child: ConstrainedBox( |
||||
constraints: const BoxConstraints( |
||||
maxWidth: 640, |
||||
), |
||||
child: ResultBuilder.behaviorSubject( |
||||
stream: bloc.state, |
||||
builder: (final context, final state) => Column( |
||||
mainAxisAlignment: MainAxisAlignment.center, |
||||
children: [ |
||||
NeonLinearProgressIndicator( |
||||
visible: state.isLoading, |
||||
), |
||||
NeonException( |
||||
state.error, |
||||
onRetry: bloc.refresh, |
||||
), |
||||
_buildAccountTile(state), |
||||
Align( |
||||
alignment: Alignment.bottomRight, |
||||
child: ElevatedButton( |
||||
onPressed: state.hasData |
||||
? () { |
||||
Provider.of<AccountsBloc>(context, listen: false) |
||||
..updateAccount(state.requireData) |
||||
..setActiveAccount(state.requireData); |
||||
|
||||
const HomeRoute().go(context); |
||||
} |
||||
: null, |
||||
child: Text(AppLocalizations.of(context).actionContinue), |
||||
), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
), |
||||
), |
||||
), |
||||
); |
||||
|
||||
Widget _buildAccountTile(final Result<Account> result) { |
||||
if (result.hasData) { |
||||
return NeonAccountTile( |
||||
account: result.requireData, |
||||
showStatus: false, |
||||
); |
||||
} |
||||
|
||||
return NeonValidationTile( |
||||
title: AppLocalizations.of(context).loginCheckingAccount, |
||||
state: ValidationState.loading, |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,123 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:neon/l10n/localizations.dart'; |
||||
import 'package:neon/src/bloc/result.dart'; |
||||
import 'package:neon/src/bloc/result_builder.dart'; |
||||
import 'package:neon/src/blocs/login_check_server_status.dart'; |
||||
import 'package:neon/src/widgets/exception.dart'; |
||||
import 'package:neon/src/widgets/linear_progress_indicator.dart'; |
||||
import 'package:neon/src/widgets/validation_tile.dart'; |
||||
import 'package:nextcloud/nextcloud.dart'; |
||||
import 'package:package_info_plus/package_info_plus.dart'; |
||||
import 'package:provider/provider.dart'; |
||||
|
||||
class LoginCheckServerStatusPage extends StatefulWidget { |
||||
const LoginCheckServerStatusPage({ |
||||
required this.serverURL, |
||||
super.key, |
||||
}); |
||||
|
||||
final String serverURL; |
||||
|
||||
@override |
||||
State<LoginCheckServerStatusPage> createState() => _LoginCheckServerStatusPageState(); |
||||
} |
||||
|
||||
class _LoginCheckServerStatusPageState extends State<LoginCheckServerStatusPage> { |
||||
late final LoginCheckServerStatusBloc bloc; |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
|
||||
bloc = LoginCheckServerStatusBloc(Provider.of<PackageInfo>(context, listen: false), widget.serverURL); |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
bloc.dispose(); |
||||
super.dispose(); |
||||
} |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) => Scaffold( |
||||
appBar: AppBar(), |
||||
body: Center( |
||||
child: Padding( |
||||
padding: const EdgeInsets.all(10), |
||||
child: ConstrainedBox( |
||||
constraints: const BoxConstraints( |
||||
maxWidth: 640, |
||||
), |
||||
child: ResultBuilder.behaviorSubject( |
||||
stream: bloc.state, |
||||
builder: (final context, final state) => Column( |
||||
mainAxisAlignment: MainAxisAlignment.center, |
||||
children: [ |
||||
NeonLinearProgressIndicator( |
||||
visible: state.isLoading, |
||||
), |
||||
NeonException( |
||||
state.error, |
||||
onRetry: bloc.refresh, |
||||
), |
||||
_buildServerVersionTile(state), |
||||
_buildMaintenanceModeTile(state), |
||||
Align( |
||||
alignment: Alignment.bottomRight, |
||||
child: ElevatedButton( |
||||
onPressed: state.hasData && state.requireData.isSupported && !state.requireData.maintenance |
||||
? () => Navigator.of(context).pop(true) |
||||
: null, |
||||
child: Text(AppLocalizations.of(context).actionContinue), |
||||
), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
), |
||||
), |
||||
), |
||||
); |
||||
|
||||
Widget _buildServerVersionTile(final Result<CoreServerStatus> result) { |
||||
if (!result.hasData) { |
||||
return NeonValidationTile( |
||||
title: AppLocalizations.of(context).loginCheckingServerVersion, |
||||
state: ValidationState.loading, |
||||
); |
||||
} |
||||
|
||||
if (result.requireData.isSupported) { |
||||
return NeonValidationTile( |
||||
title: AppLocalizations.of(context).loginSupportedServerVersion(result.requireData.versionstring), |
||||
state: ValidationState.success, |
||||
); |
||||
} |
||||
|
||||
return NeonValidationTile( |
||||
title: AppLocalizations.of(context).loginUnsupportedServerVersion(result.requireData.versionstring), |
||||
state: ValidationState.failure, |
||||
); |
||||
} |
||||
|
||||
Widget _buildMaintenanceModeTile(final Result<CoreServerStatus> result) { |
||||
if (!result.hasData) { |
||||
return NeonValidationTile( |
||||
title: AppLocalizations.of(context).loginCheckingMaintenanceMode, |
||||
state: ValidationState.loading, |
||||
); |
||||
} |
||||
|
||||
if (result.requireData.maintenance) { |
||||
return NeonValidationTile( |
||||
title: AppLocalizations.of(context).loginMaintenanceModeEnabled, |
||||
state: ValidationState.failure, |
||||
); |
||||
} |
||||
|
||||
return NeonValidationTile( |
||||
title: AppLocalizations.of(context).loginMaintenanceModeDisabled, |
||||
state: ValidationState.success, |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:neon/l10n/localizations.dart'; |
||||
import 'package:neon/src/bloc/result_builder.dart'; |
||||
import 'package:neon/src/blocs/login_flow.dart'; |
||||
import 'package:neon/src/router.dart'; |
||||
import 'package:neon/src/widgets/exception.dart'; |
||||
import 'package:neon/src/widgets/linear_progress_indicator.dart'; |
||||
import 'package:package_info_plus/package_info_plus.dart'; |
||||
import 'package:provider/provider.dart'; |
||||
import 'package:url_launcher/url_launcher_string.dart'; |
||||
|
||||
class LoginFlowPage extends StatefulWidget { |
||||
const LoginFlowPage({ |
||||
required this.serverURL, |
||||
super.key, |
||||
}); |
||||
|
||||
final String serverURL; |
||||
|
||||
@override |
||||
State<LoginFlowPage> createState() => _LoginFlowPageState(); |
||||
} |
||||
|
||||
class _LoginFlowPageState extends State<LoginFlowPage> { |
||||
late final LoginFlowBloc bloc; |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
|
||||
bloc = LoginFlowBloc(Provider.of<PackageInfo>(context, listen: false), widget.serverURL); |
||||
|
||||
bloc.init.listen((final result) async { |
||||
if (result.hasData) { |
||||
await launchUrlString( |
||||
result.requireData.login, |
||||
mode: LaunchMode.externalApplication, |
||||
); |
||||
} |
||||
}); |
||||
|
||||
bloc.result.listen((final result) { |
||||
LoginCheckAccountRoute( |
||||
serverURL: result.server, |
||||
loginName: result.loginName, |
||||
password: result.appPassword, |
||||
).pushReplacement(context); |
||||
}); |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
bloc.dispose(); |
||||
super.dispose(); |
||||
} |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) => Scaffold( |
||||
appBar: AppBar(), |
||||
body: Center( |
||||
child: ResultBuilder.behaviorSubject( |
||||
stream: bloc.init, |
||||
builder: (final context, final init) => Column( |
||||
mainAxisAlignment: MainAxisAlignment.center, |
||||
children: [ |
||||
NeonLinearProgressIndicator( |
||||
visible: init.isLoading, |
||||
), |
||||
NeonException( |
||||
init.error, |
||||
onRetry: bloc.refresh, |
||||
), |
||||
if (init.hasData) ...[ |
||||
Text(AppLocalizations.of(context).loginSwitchToBrowserWindow), |
||||
const SizedBox( |
||||
height: 10, |
||||
), |
||||
ElevatedButton( |
||||
onPressed: bloc.refresh, |
||||
child: Text(AppLocalizations.of(context).loginOpenAgain), |
||||
), |
||||
], |
||||
], |
||||
), |
||||
), |
||||
), |
||||
); |
||||
} |
@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart'; |
||||
|
||||
class NeonValidationTile extends StatelessWidget { |
||||
const NeonValidationTile({ |
||||
required this.title, |
||||
required this.state, |
||||
super.key, |
||||
}); |
||||
|
||||
final String title; |
||||
final ValidationState state; |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) { |
||||
const size = 32.0; |
||||
|
||||
final leading = switch (state) { |
||||
ValidationState.loading => const SizedBox( |
||||
width: size, |
||||
height: size, |
||||
child: CircularProgressIndicator( |
||||
strokeWidth: 3, |
||||
), |
||||
), |
||||
ValidationState.failure => Icon( |
||||
Icons.error_outline, |
||||
color: Theme.of(context).colorScheme.error, |
||||
size: size, |
||||
), |
||||
ValidationState.success => Icon( |
||||
Icons.check_circle, |
||||
color: Theme.of(context).colorScheme.primary, |
||||
size: size, |
||||
), |
||||
}; |
||||
return ListTile( |
||||
leading: leading, |
||||
title: Text(title), |
||||
); |
||||
} |
||||
} |
||||
|
||||
enum ValidationState { |
||||
loading, |
||||
failure, |
||||
success, |
||||
} |
Loading…
Reference in new issue