Browse Source

Merge pull request #452 from provokateurin/refactor/login-flow

Refactor/login flow
pull/457/head
Kate 1 year ago committed by GitHub
parent
commit
099d6b88fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 29
      packages/neon/neon/lib/l10n/en.arb
  2. 54
      packages/neon/neon/lib/l10n/localizations.dart
  3. 31
      packages/neon/neon/lib/l10n/localizations_en.dart
  4. 9
      packages/neon/neon/lib/neon.dart
  5. 4
      packages/neon/neon/lib/src/blocs/apps.dart
  6. 120
      packages/neon/neon/lib/src/blocs/login.dart
  7. 69
      packages/neon/neon/lib/src/blocs/login_check_account.dart
  8. 51
      packages/neon/neon/lib/src/blocs/login_check_server_status.dart
  9. 77
      packages/neon/neon/lib/src/blocs/login_flow.dart
  10. 32
      packages/neon/neon/lib/src/models/account.dart
  11. 2
      packages/neon/neon/lib/src/pages/home.dart
  12. 193
      packages/neon/neon/lib/src/pages/login.dart
  13. 109
      packages/neon/neon/lib/src/pages/login_check_account.dart
  14. 120
      packages/neon/neon/lib/src/pages/login_check_server_status.dart
  15. 86
      packages/neon/neon/lib/src/pages/login_flow.dart
  16. 71
      packages/neon/neon/lib/src/router.dart
  17. 71
      packages/neon/neon/lib/src/router.g.dart
  18. 23
      packages/neon/neon/lib/src/utils/theme.dart
  19. 3
      packages/neon/neon/lib/src/widgets/account_tile.dart
  20. 2
      packages/neon/neon/lib/src/widgets/exception.dart
  21. 47
      packages/neon/neon/lib/src/widgets/validation_tile.dart
  22. 1
      packages/neon/neon/pubspec.yaml
  23. 10
      packages/nextcloud/lib/src/version_supported.dart
  24. 9
      packages/nextcloud/test/core_test.dart
  25. 2
      packages/nextcloud/test/notes_test.dart

29
packages/neon/neon/lib/l10n/en.arb

@ -11,7 +11,27 @@
"loginSwitchToBrowserWindow": "Please switch to the browser window that just opened and proceed there", "loginSwitchToBrowserWindow": "Please switch to the browser window that just opened and proceed there",
"loginWorksWith": "works with", "loginWorksWith": "works with",
"loginRestart": "Restart login", "loginRestart": "Restart login",
"errorAccountAlreadyExists": "The account you are trying to add already exists", "loginCheckingServerVersion": "Checking server version",
"loginSupportedServerVersion": "Supported server version: {version}",
"@loginSupportedServerVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"loginUnsupportedServerVersion": "Unsupported server version: {version}",
"@loginUnsupportedServerVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"loginCheckingMaintenanceMode": "Checking maintenance mode",
"loginMaintenanceModeEnabled": "Maintenance mode enabled",
"loginMaintenanceModeDisabled": "Maintenance mode disabled",
"loginCheckingAccount": "Checking account",
"errorCredentialsForAccountNoLongerMatch": "The credentials for this account no longer match", "errorCredentialsForAccountNoLongerMatch": "The credentials for this account no longer match",
"errorServerHadAProblemProcessingYourRequest": "The server had a problem while processing your request. You might want to try again", "errorServerHadAProblemProcessingYourRequest": "The server had a problem while processing your request. You might want to try again",
"errorSomethingWentWrongTryAgainLater": "Something went wrong. Please try again later", "errorSomethingWentWrongTryAgainLater": "Something went wrong. Please try again later",
@ -36,10 +56,10 @@
} }
}, },
"errorUnableToOpenFile": "Unable to open the file", "errorUnableToOpenFile": "Unable to open the file",
"errorUnsupportedVersion": "Sorry, the version of the following apps on your Nextcloud instance are not supported. \n {unsupported} \n Please contact your administrator to resolve the issues.", "errorUnsupportedAppVersions": "Sorry, the version of the following apps on your Nextcloud instance are not supported. \n {names} \n Please contact your administrator to resolve the issues.",
"@errorUnsupportedVersion" : { "@errorUnsupportedAppVersions" : {
"placeholders": { "placeholders": {
"unsupported": { "names": {
"type": "String" "type": "String"
} }
} }
@ -52,6 +72,7 @@
"actionRetry": "Retry", "actionRetry": "Retry",
"actionShowSlashHide": "Show/Hide", "actionShowSlashHide": "Show/Hide",
"actionExit": "Exit", "actionExit": "Exit",
"actionContinue": "Continue",
"firstLaunchGoToSettingsToEnablePushNotifications": "Go to the settings to enable push notifications", "firstLaunchGoToSettingsToEnablePushNotifications": "Go to the settings to enable push notifications",
"nextPushSupported": "NextPush is supported!", "nextPushSupported": "NextPush is supported!",
"nextPushSupportedText": "NextPush is a FOSS way of receiving push notifications using the UnifiedPush protocol via a Nextcloud instance.\nYou can install NextPush from the F-Droid app store.", "nextPushSupportedText": "NextPush is a FOSS way of receiving push notifications using the UnifiedPush protocol via a Nextcloud instance.\nYou can install NextPush from the F-Droid app store.",

54
packages/neon/neon/lib/l10n/localizations.dart

@ -125,11 +125,47 @@ abstract class AppLocalizations {
/// **'Restart login'** /// **'Restart login'**
String get loginRestart; String get loginRestart;
/// No description provided for @errorAccountAlreadyExists. /// No description provided for @loginCheckingServerVersion.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'The account you are trying to add already exists'** /// **'Checking server version'**
String get errorAccountAlreadyExists; String get loginCheckingServerVersion;
/// No description provided for @loginSupportedServerVersion.
///
/// In en, this message translates to:
/// **'Supported server version: {version}'**
String loginSupportedServerVersion(String version);
/// No description provided for @loginUnsupportedServerVersion.
///
/// In en, this message translates to:
/// **'Unsupported server version: {version}'**
String loginUnsupportedServerVersion(String version);
/// No description provided for @loginCheckingMaintenanceMode.
///
/// In en, this message translates to:
/// **'Checking maintenance mode'**
String get loginCheckingMaintenanceMode;
/// No description provided for @loginMaintenanceModeEnabled.
///
/// In en, this message translates to:
/// **'Maintenance mode enabled'**
String get loginMaintenanceModeEnabled;
/// No description provided for @loginMaintenanceModeDisabled.
///
/// In en, this message translates to:
/// **'Maintenance mode disabled'**
String get loginMaintenanceModeDisabled;
/// No description provided for @loginCheckingAccount.
///
/// In en, this message translates to:
/// **'Checking account'**
String get loginCheckingAccount;
/// No description provided for @errorCredentialsForAccountNoLongerMatch. /// No description provided for @errorCredentialsForAccountNoLongerMatch.
/// ///
@ -191,11 +227,11 @@ abstract class AppLocalizations {
/// **'Unable to open the file'** /// **'Unable to open the file'**
String get errorUnableToOpenFile; String get errorUnableToOpenFile;
/// No description provided for @errorUnsupportedVersion. /// No description provided for @errorUnsupportedAppVersions.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Sorry, the version of the following apps on your Nextcloud instance are not supported. \n {unsupported} \n Please contact your administrator to resolve the issues.'** /// **'Sorry, the version of the following apps on your Nextcloud instance are not supported. \n {names} \n Please contact your administrator to resolve the issues.'**
String errorUnsupportedVersion(String unsupported); String errorUnsupportedAppVersions(String names);
/// No description provided for @errorEmptyField. /// No description provided for @errorEmptyField.
/// ///
@ -245,6 +281,12 @@ abstract class AppLocalizations {
/// **'Exit'** /// **'Exit'**
String get actionExit; String get actionExit;
/// No description provided for @actionContinue.
///
/// In en, this message translates to:
/// **'Continue'**
String get actionContinue;
/// No description provided for @firstLaunchGoToSettingsToEnablePushNotifications. /// No description provided for @firstLaunchGoToSettingsToEnablePushNotifications.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

31
packages/neon/neon/lib/l10n/localizations_en.dart

@ -39,7 +39,29 @@ class AppLocalizationsEn extends AppLocalizations {
String get loginRestart => 'Restart login'; String get loginRestart => 'Restart login';
@override @override
String get errorAccountAlreadyExists => 'The account you are trying to add already exists'; String get loginCheckingServerVersion => 'Checking server version';
@override
String loginSupportedServerVersion(String version) {
return 'Supported server version: $version';
}
@override
String loginUnsupportedServerVersion(String version) {
return 'Unsupported server version: $version';
}
@override
String get loginCheckingMaintenanceMode => 'Checking maintenance mode';
@override
String get loginMaintenanceModeEnabled => 'Maintenance mode enabled';
@override
String get loginMaintenanceModeDisabled => 'Maintenance mode disabled';
@override
String get loginCheckingAccount => 'Checking account';
@override @override
String get errorCredentialsForAccountNoLongerMatch => 'The credentials for this account no longer match'; String get errorCredentialsForAccountNoLongerMatch => 'The credentials for this account no longer match';
@ -79,8 +101,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get errorUnableToOpenFile => 'Unable to open the file'; String get errorUnableToOpenFile => 'Unable to open the file';
@override @override
String errorUnsupportedVersion(String unsupported) { String errorUnsupportedAppVersions(String names) {
return 'Sorry, the version of the following apps on your Nextcloud instance are not supported. \n $unsupported \n Please contact your administrator to resolve the issues.'; return 'Sorry, the version of the following apps on your Nextcloud instance are not supported. \n $names \n Please contact your administrator to resolve the issues.';
} }
@override @override
@ -107,6 +129,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get actionExit => 'Exit'; String get actionExit => 'Exit';
@override
String get actionContinue => 'Continue';
@override @override
String get firstLaunchGoToSettingsToEnablePushNotifications => 'Go to the settings to enable push notifications'; String get firstLaunchGoToSettingsToEnablePushNotifications => 'Go to the settings to enable push notifications';

9
packages/neon/neon/lib/neon.dart

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:meta/meta.dart';
import 'package:neon/src/app.dart'; import 'package:neon/src/app.dart';
import 'package:neon/src/blocs/accounts.dart'; import 'package:neon/src/blocs/accounts.dart';
import 'package:neon/src/blocs/first_launch.dart'; import 'package:neon/src/blocs/first_launch.dart';
@ -17,6 +18,9 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@internal
late final String neonUserAgent;
Future runNeon({ Future runNeon({
required final Iterable<AppImplementation> Function(SharedPreferences, RequestManager, NeonPlatform) required final Iterable<AppImplementation> Function(SharedPreferences, RequestManager, NeonPlatform)
getAppImplementations, getAppImplementations,
@ -39,6 +43,11 @@ Future runNeon({
final allAppImplementations = getAppImplementations(sharedPreferences, requestManager, platform); final allAppImplementations = getAppImplementations(sharedPreferences, requestManager, platform);
final packageInfo = await PackageInfo.fromPlatform(); final packageInfo = await PackageInfo.fromPlatform();
var buildNumber = packageInfo.buildNumber;
if (buildNumber.isEmpty) {
buildNumber = '1';
}
neonUserAgent = 'Neon ${packageInfo.version}+$buildNumber';
final globalOptions = GlobalOptions( final globalOptions = GlobalOptions(
sharedPreferences, sharedPreferences,

4
packages/neon/neon/lib/src/blocs/apps.dart

@ -105,9 +105,9 @@ class AppsBloc extends InteractiveBloc implements AppsBlocEvents, AppsBlocStates
for (final id in appIds) { for (final id in appIds) {
try { try {
final (supported, minVersion) = switch (id) { final (supported, minVersion) = switch (id) {
'core' => await _account.client.core.isSupported(capabilities.requireData), 'core' => _account.client.core.isSupported(capabilities.requireData),
'news' => await _account.client.news.isSupported(), 'news' => await _account.client.news.isSupported(),
'notes' => await _account.client.notes.isSupported(capabilities.requireData), 'notes' => _account.client.notes.isSupported(capabilities.requireData),
_ => (true, null), _ => (true, null),
}; };

120
packages/neon/neon/lib/src/blocs/login.dart

@ -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,
}

69
packages/neon/neon/lib/src/blocs/login_check_account.dart

@ -0,0 +1,69 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:neon/neon.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: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.serverURL,
this.loginName,
this.password,
) {
unawaited(refresh());
}
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: neonUserAgent,
);
final response = await client.provisioningApi.getCurrentUser();
final account = Account(
serverURL: serverURL,
loginName: loginName,
username: response.ocs.data.id,
password: password,
userAgent: neonUserAgent,
);
state.add(Result.success(account));
} catch (e, s) {
debugPrint(e.toString());
debugPrint(s.toString());
state.add(Result.error(e));
}
}
}

51
packages/neon/neon/lib/src/blocs/login_check_server_status.dart

@ -0,0 +1,51 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:neon/neon.dart';
import 'package:neon/src/bloc/bloc.dart';
import 'package:neon/src/bloc/result.dart';
import 'package:nextcloud/nextcloud.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.serverURL) {
unawaited(refresh());
}
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: neonUserAgent,
);
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));
}
}
}

77
packages/neon/neon/lib/src/blocs/login_flow.dart

@ -0,0 +1,77 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:neon/neon.dart';
import 'package:neon/src/bloc/bloc.dart';
import 'package:neon/src/bloc/result.dart';
import 'package:nextcloud/nextcloud.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.serverURL) {
unawaited(refresh());
}
final String serverURL;
late final _client = NextcloudClient(
serverURL,
userAgentOverride: neonUserAgent,
);
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;
}
}
}

32
packages/neon/neon/lib/src/models/account.dart

@ -5,41 +5,9 @@ import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/nextcloud.dart';
import 'package:package_info_plus/package_info_plus.dart';
part 'account.g.dart'; part 'account.g.dart';
Future<Account> getAccount(
final PackageInfo packageInfo,
final String serverURL,
final String loginName,
final String password,
) async {
final username = (await NextcloudClient(
serverURL,
loginName: loginName,
password: password,
).provisioningApi.getCurrentUser())
.ocs
.data
.id;
return Account(
serverURL: serverURL,
loginName: loginName,
username: username,
password: password,
userAgent: userAgent(packageInfo),
);
}
String userAgent(final PackageInfo packageInfo) {
var buildNumber = packageInfo.buildNumber;
if (buildNumber.isEmpty) {
buildNumber = '1';
}
return 'Neon ${packageInfo.version}+$buildNumber';
}
@JsonSerializable() @JsonSerializable()
@immutable @immutable
class Account { class Account {

2
packages/neon/neon/lib/src/pages/home.dart

@ -63,7 +63,7 @@ class _HomePageState extends State<HomePage> {
} }
} }
final message = l10n.errorUnsupportedVersion(buffer.toString()); final message = l10n.errorUnsupportedAppVersions(buffer.toString());
unawaited(_showProblem(message)); unawaited(_showProblem(message));
}); });

193
packages/neon/neon/lib/src/pages/login.dart

@ -1,19 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:neon/l10n/localizations.dart'; import 'package:neon/l10n/localizations.dart';
import 'package:neon/src/blocs/accounts.dart';
import 'package:neon/src/blocs/login.dart';
import 'package:neon/src/models/account.dart';
import 'package:neon/src/models/branding.dart'; import 'package:neon/src/models/branding.dart';
import 'package:neon/src/platform/platform.dart';
import 'package:neon/src/router.dart'; import 'package:neon/src/router.dart';
import 'package:neon/src/utils/theme.dart';
import 'package:neon/src/utils/validators.dart'; import 'package:neon/src/utils/validators.dart';
import 'package:neon/src/widgets/exception.dart';
import 'package:neon/src/widgets/linear_progress_indicator.dart';
import 'package:neon/src/widgets/nextcloud_logo.dart'; import 'package:neon/src/widgets/nextcloud_logo.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:webview_flutter/webview_flutter.dart';
class LoginPage extends StatefulWidget { class LoginPage extends StatefulWidget {
const LoginPage({ const LoginPage({
@ -28,166 +20,47 @@ class LoginPage extends StatefulWidget {
} }
class _LoginPageState extends State<LoginPage> { class _LoginPageState extends State<LoginPage> {
late WebViewController? _webViewController;
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _focusNode = FocusNode(); final _focusNode = FocusNode();
late final PackageInfo _packageInfo;
late final AccountsBloc _accountsBloc;
late final LoginBloc _loginBloc;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_packageInfo = Provider.of<PackageInfo>(context, listen: false);
_accountsBloc = Provider.of<AccountsBloc>(context, listen: false);
_loginBloc = LoginBloc(_packageInfo);
if (widget.serverURL != null) { if (widget.serverURL != null) {
_loginBloc.setServerURL(widget.serverURL); WidgetsBinding.instance.addPostFrameCallback((final _) async {
} await _beginLoginFlow(widget.serverURL!);
WidgetsBinding.instance.addPostFrameCallback((final _) {
if (Provider.of<NeonPlatform>(context, listen: false).canUseWebView) {
_webViewController = WebViewController()
// ignore: discarded_futures
..setJavaScriptMode(JavaScriptMode.unrestricted)
// ignore: discarded_futures
..enableZoom(false)
// ignore: discarded_futures
..setUserAgent(userAgent(_packageInfo));
}
_loginBloc.loginFlowInit.listen((final init) async {
if (init != null) {
if (Provider.of<NeonPlatform>(context, listen: false).canUseWebView) {
await _webViewController!.loadRequest(Uri.parse(init.login));
} else {
await launchUrlString(
init.login,
mode: LaunchMode.externalApplication,
);
}
}
}); });
});
_loginBloc.loginFlowResult.listen((final result) async {
if (result != null) {
try {
final account = await getAccount(
_packageInfo,
result.server,
result.loginName,
result.appPassword,
);
if (!mounted) {
return;
} }
if (widget.serverURL != null) {
_accountsBloc.updateAccount(account);
} else {
final existingAccount = _accountsBloc.accounts.value.tryFind(account.id);
if (existingAccount != null) {
NeonException.showSnackbar(context, AppLocalizations.of(context).errorAccountAlreadyExists);
await _loginBloc.refresh();
return;
}
_accountsBloc
..addAccount(account)
..setActiveAccount(account);
}
if (mounted) {
const HomeRoute().go(context);
}
} catch (e, s) {
debugPrint(e.toString());
debugPrint(s.toString());
NeonException.showSnackbar(context, e);
await _loginBloc.refresh();
}
}
});
} }
@override @override
void dispose() { void dispose() {
_loginBloc.dispose(); _focusNode.dispose();
super.dispose(); super.dispose();
} }
@override Future _beginLoginFlow(final String serverURL) async {
Widget build(final BuildContext context) => StreamBuilder<List<Account>>( final result = await LoginCheckServerStatusRoute(serverURL: serverURL).push<bool>(context);
stream: _accountsBloc.accounts, if ((result ?? false) && mounted) {
builder: (final context, final accountsSnapshot) => BackButtonListener( // This needs be done, otherwise the context is dirty after returning from the previously pushed route
onBackButtonPressed: () async { WidgetsBinding.instance.addPostFrameCallback((final _) async {
if (accountsSnapshot.data?.isNotEmpty ?? false) { await LoginFlowRoute(serverURL: serverURL).push(context);
return false; });
} }
if ((await _loginBloc.serverURL.first) == null) {
return false;
} }
_loginBloc.setServerURL(null); @override
return true; Widget build(final BuildContext context) {
}, final branding = Provider.of<Branding>(context, listen: false);
child: StreamBuilder<String?>( return Scaffold(
stream: _loginBloc.serverURL,
builder: (final context, final serverURLSnapshot) => StreamBuilder<ServerConnectionState?>(
stream: _loginBloc.serverConnectionState,
builder: (final context, final serverConnectionStateSnapshot) => Scaffold(
resizeToAvoidBottomInset: true, resizeToAvoidBottomInset: true,
appBar: serverConnectionStateSnapshot.data == ServerConnectionState.success || appBar: AppBar(
(accountsSnapshot.data?.isNotEmpty ?? false) leading: Navigator.of(context).canPop() ? const CloseButton() : null,
? AppBar(
leading: BackButton(
onPressed: () {
if (accountsSnapshot.data?.isNotEmpty ?? false) {
Navigator.of(context).pop();
} else {
_loginBloc.setServerURL(null);
}
},
),
actions: [
if (serverConnectionStateSnapshot.hasData) ...[
IconButton(
onPressed: _loginBloc.refresh,
tooltip: AppLocalizations.of(context).loginRestart,
icon: const Icon(Icons.refresh),
),
],
],
)
: null,
body: serverConnectionStateSnapshot.data == ServerConnectionState.success
? Provider.of<NeonPlatform>(context).canUseWebView
? WebViewWidget(
controller: _webViewController!,
)
: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(AppLocalizations.of(context).loginSwitchToBrowserWindow),
const SizedBox(
height: 10,
),
ElevatedButton(
onPressed: _loginBloc.refresh,
child: Text(AppLocalizations.of(context).loginOpenAgain),
), ),
], body: Center(
), child: ConstrainedBox(
) constraints: Theme.of(context).extension<NeonTheme>()?.tabletLayout ?? const BoxConstraints(),
: Builder(
builder: (final context) {
final branding = Provider.of<Branding>(context, listen: false);
return Center(
child: Scrollbar( child: Scrollbar(
interactive: true, interactive: true,
child: SingleChildScrollView( child: SingleChildScrollView(
@ -218,37 +91,16 @@ class _LoginPageState extends State<LoginPage> {
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
initialValue: widget.serverURL, initialValue: widget.serverURL,
validator: (final input) => validateHttpUrl(context, input), validator: (final input) => validateHttpUrl(context, input),
onFieldSubmitted: (final input) { onFieldSubmitted: (final input) async {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
_loginBloc.setServerURL(input); await _beginLoginFlow(input);
} else { } else {
_focusNode.requestFocus(); _focusNode.requestFocus();
} }
}, },
), ),
), ),
NeonLinearProgressIndicator(
visible: serverConnectionStateSnapshot.data == ServerConnectionState.loading,
),
if (serverConnectionStateSnapshot.data == ServerConnectionState.unreachable) ...[
NeonException(
AppLocalizations.of(context).errorUnableToReachServer,
onRetry: _loginBloc.refresh,
),
], ],
if (serverConnectionStateSnapshot.data ==
ServerConnectionState.maintenanceMode) ...[
NeonException(
AppLocalizations.of(context).errorServerInMaintenanceMode,
onRetry: _loginBloc.refresh,
),
],
],
),
),
),
);
},
), ),
), ),
), ),
@ -256,3 +108,4 @@ class _LoginPageState extends State<LoginPage> {
), ),
); );
} }
}

109
packages/neon/neon/lib/src/pages/login_check_account.dart

@ -0,0 +1,109 @@
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/utils/theme.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: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(
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: Theme.of(context).extension<NeonTheme>()?.tabletLayout ?? const BoxConstraints(),
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,
);
}
}

120
packages/neon/neon/lib/src/pages/login_check_server_status.dart

@ -0,0 +1,120 @@
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/utils/theme.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';
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(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: Theme.of(context).extension<NeonTheme>()?.tabletLayout ?? const BoxConstraints(),
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,
);
}
}

86
packages/neon/neon/lib/src/pages/login_flow.dart

@ -0,0 +1,86 @@
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: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(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),
),
],
],
),
),
),
);
}

71
packages/neon/neon/lib/src/router.dart

@ -7,6 +7,9 @@ import 'package:neon/src/models/app_implementation.dart';
import 'package:neon/src/pages/account_settings.dart'; import 'package:neon/src/pages/account_settings.dart';
import 'package:neon/src/pages/home.dart'; import 'package:neon/src/pages/home.dart';
import 'package:neon/src/pages/login.dart'; import 'package:neon/src/pages/login.dart';
import 'package:neon/src/pages/login_check_account.dart';
import 'package:neon/src/pages/login_check_server_status.dart';
import 'package:neon/src/pages/login_flow.dart';
import 'package:neon/src/pages/nextcloud_app_settings.dart'; import 'package:neon/src/pages/nextcloud_app_settings.dart';
import 'package:neon/src/pages/settings.dart'; import 'package:neon/src/pages/settings.dart';
import 'package:neon/src/utils/stream_listenable.dart'; import 'package:neon/src/utils/stream_listenable.dart';
@ -27,7 +30,7 @@ class AppRouter extends GoRouter {
final account = accountsBloc.activeAccount.valueOrNull; final account = accountsBloc.activeAccount.valueOrNull;
// redirect to loginscreen when no account is logged in // redirect to loginscreen when no account is logged in
if (account == null) { if (account == null && !state.location.startsWith(const LoginRoute().location)) {
return const LoginRoute().location; return const LoginRoute().location;
} }
@ -97,15 +100,75 @@ class HomeRoute extends GoRouteData {
@TypedGoRoute<LoginRoute>( @TypedGoRoute<LoginRoute>(
path: '/login', path: '/login',
name: 'login', name: 'login',
routes: [
TypedGoRoute<LoginFlowRoute>(
path: 'flow/:serverURL',
name: 'loginFlow',
),
TypedGoRoute<LoginCheckServerStatusRoute>(
path: 'check/server/:serverURL',
name: 'checkServerStatus',
),
TypedGoRoute<LoginCheckAccountRoute>(
path: 'check/account/:serverURL/:loginName/:password',
name: 'checkAccount',
),
],
) )
@immutable @immutable
class LoginRoute extends GoRouteData { class LoginRoute extends GoRouteData {
const LoginRoute({this.server}); const LoginRoute({this.serverURL});
final String? serverURL;
@override
Widget build(final BuildContext context, final GoRouterState state) => LoginPage(serverURL: serverURL);
}
@immutable
class LoginFlowRoute extends GoRouteData {
const LoginFlowRoute({
required this.serverURL,
});
final String serverURL;
@override
Widget build(final BuildContext context, final GoRouterState state) => LoginFlowPage(serverURL: serverURL);
}
@immutable
class LoginCheckServerStatusRoute extends GoRouteData {
const LoginCheckServerStatusRoute({
required this.serverURL,
});
final String serverURL;
@override
Widget build(final BuildContext context, final GoRouterState state) => LoginCheckServerStatusPage(
serverURL: serverURL,
);
}
final String? server; @immutable
class LoginCheckAccountRoute extends GoRouteData {
const LoginCheckAccountRoute({
required this.serverURL,
required this.loginName,
required this.password,
});
final String serverURL;
final String loginName;
final String password;
@override @override
Widget build(final BuildContext context, final GoRouterState state) => LoginPage(serverURL: server); Widget build(final BuildContext context, final GoRouterState state) => LoginCheckAccountPage(
serverURL: serverURL,
loginName: loginName,
password: password,
);
} }
@immutable @immutable

71
packages/neon/neon/lib/src/router.g.dart

@ -119,17 +119,34 @@ RouteBase get $loginRoute => GoRouteData.$route(
path: '/login', path: '/login',
name: 'login', name: 'login',
factory: $LoginRouteExtension._fromState, factory: $LoginRouteExtension._fromState,
routes: [
GoRouteData.$route(
path: 'flow/:serverURL',
name: 'loginFlow',
factory: $LoginFlowRouteExtension._fromState,
),
GoRouteData.$route(
path: 'check/server/:serverURL',
name: 'checkServerStatus',
factory: $LoginCheckServerStatusRouteExtension._fromState,
),
GoRouteData.$route(
path: 'check/account/:serverURL/:loginName/:password',
name: 'checkAccount',
factory: $LoginCheckAccountRouteExtension._fromState,
),
],
); );
extension $LoginRouteExtension on LoginRoute { extension $LoginRouteExtension on LoginRoute {
static LoginRoute _fromState(GoRouterState state) => LoginRoute( static LoginRoute _fromState(GoRouterState state) => LoginRoute(
server: state.queryParameters['server'], serverURL: state.queryParameters['server-u-r-l'],
); );
String get location => GoRouteData.$location( String get location => GoRouteData.$location(
'/login', '/login',
queryParams: { queryParams: {
if (server != null) 'server': server, if (serverURL != null) 'server-u-r-l': serverURL,
}, },
); );
@ -139,3 +156,53 @@ extension $LoginRouteExtension on LoginRoute {
void pushReplacement(BuildContext context) => context.pushReplacement(location); void pushReplacement(BuildContext context) => context.pushReplacement(location);
} }
extension $LoginFlowRouteExtension on LoginFlowRoute {
static LoginFlowRoute _fromState(GoRouterState state) => LoginFlowRoute(
serverURL: state.pathParameters['serverURL']!,
);
String get location => GoRouteData.$location(
'/login/flow/${Uri.encodeComponent(serverURL)}',
);
void go(BuildContext context) => context.go(location);
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
void pushReplacement(BuildContext context) => context.pushReplacement(location);
}
extension $LoginCheckServerStatusRouteExtension on LoginCheckServerStatusRoute {
static LoginCheckServerStatusRoute _fromState(GoRouterState state) => LoginCheckServerStatusRoute(
serverURL: state.pathParameters['serverURL']!,
);
String get location => GoRouteData.$location(
'/login/check/server/${Uri.encodeComponent(serverURL)}',
);
void go(BuildContext context) => context.go(location);
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
void pushReplacement(BuildContext context) => context.pushReplacement(location);
}
extension $LoginCheckAccountRouteExtension on LoginCheckAccountRoute {
static LoginCheckAccountRoute _fromState(GoRouterState state) => LoginCheckAccountRoute(
serverURL: state.pathParameters['serverURL']!,
loginName: state.pathParameters['loginName']!,
password: state.pathParameters['password']!,
);
String get location => GoRouteData.$location(
'/login/check/account/${Uri.encodeComponent(serverURL)}/${Uri.encodeComponent(loginName)}/${Uri.encodeComponent(password)}',
);
void go(BuildContext context) => context.go(location);
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
void pushReplacement(BuildContext context) => context.pushReplacement(location);
}

23
packages/neon/neon/lib/src/utils/theme.dart

@ -48,7 +48,11 @@ class AppTheme {
snackBarTheme: _snackBarTheme, snackBarTheme: _snackBarTheme,
dividerTheme: _dividerTheme, dividerTheme: _dividerTheme,
extensions: [ extensions: [
const NeonTheme(), const NeonTheme(
tabletLayout: BoxConstraints(
maxWidth: 640,
),
),
...?appThemes, ...?appThemes,
], ],
); );
@ -70,16 +74,27 @@ class AppTheme {
@internal @internal
@immutable @immutable
class NeonTheme extends ThemeExtension<NeonTheme> { class NeonTheme extends ThemeExtension<NeonTheme> {
const NeonTheme(); const NeonTheme({
required this.tabletLayout,
});
final BoxConstraints? tabletLayout;
@override @override
NeonTheme copyWith() => const NeonTheme(); NeonTheme copyWith({
final BoxConstraints? tabletLayout,
}) =>
NeonTheme(
tabletLayout: tabletLayout ?? this.tabletLayout,
);
@override @override
NeonTheme lerp(final NeonTheme? other, final double t) { NeonTheme lerp(final NeonTheme? other, final double t) {
if (other is! NeonTheme) { if (other is! NeonTheme) {
return this; return this;
} }
return const NeonTheme(); return NeonTheme(
tabletLayout: BoxConstraints.lerp(tabletLayout, other.tabletLayout, t),
);
} }
} }

3
packages/neon/neon/lib/src/widgets/account_tile.dart

@ -18,6 +18,7 @@ class NeonAccountTile extends StatelessWidget {
this.onTap, this.onTap,
this.textColor, this.textColor,
this.dense = false, this.dense = false,
this.showStatus = true,
super.key, super.key,
}); });
@ -27,6 +28,7 @@ class NeonAccountTile extends StatelessWidget {
final VoidCallback? onTap; final VoidCallback? onTap;
final Color? textColor; final Color? textColor;
final bool dense; final bool dense;
final bool showStatus;
@override @override
Widget build(final BuildContext context) { Widget build(final BuildContext context) {
@ -45,6 +47,7 @@ class NeonAccountTile extends StatelessWidget {
: null, : null,
leading: NeonUserAvatar( leading: NeonUserAvatar(
account: account, account: account,
showStatus: showStatus,
), ),
title: ResultBuilder<ProvisioningApiUserDetails>.behaviorSubject( title: ResultBuilder<ProvisioningApiUserDetails>.behaviorSubject(
stream: userDetailsBloc.userDetails, stream: userDetailsBloc.userDetails,

2
packages/neon/neon/lib/src/widgets/exception.dart

@ -180,7 +180,7 @@ class NeonException extends StatelessWidget {
static void _openLoginPage(final BuildContext context) { static void _openLoginPage(final BuildContext context) {
LoginRoute( LoginRoute(
server: Provider.of<AccountsBloc>(context, listen: false).activeAccount.value!.serverURL, serverURL: Provider.of<AccountsBloc>(context, listen: false).activeAccount.value!.serverURL,
).go(context); ).go(context);
} }
} }

47
packages/neon/neon/lib/src/widgets/validation_tile.dart

@ -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,
}

1
packages/neon/neon/pubspec.yaml

@ -49,7 +49,6 @@ dependencies:
tray_manager: ^0.2.0 tray_manager: ^0.2.0
unifiedpush: ^5.0.0 unifiedpush: ^5.0.0
url_launcher: ^6.1.11 url_launcher: ^6.1.11
webview_flutter: ^4.2.0
window_manager: ^0.3.2 window_manager: ^0.3.2
xdg_directories: ^1.0.0 xdg_directories: ^1.0.0
xml: ^6.3.0 xml: ^6.3.0

10
packages/nextcloud/lib/src/version_supported.dart

@ -17,12 +17,18 @@ extension CoreVersionSupported on CoreClient {
/// Check if the core/Server version is supported by this client /// Check if the core/Server version is supported by this client
/// ///
/// Also returns the supported version number /// Also returns the supported version number
Future<(bool, int)> isSupported(final CoreServerCapabilities_Ocs_Data capabilities) async => ( (bool, int) isSupported(final CoreServerCapabilities_Ocs_Data capabilities) => (
capabilities.version.major == coreSupportedVersion, capabilities.version.major == coreSupportedVersion,
coreSupportedVersion, coreSupportedVersion,
); );
} }
// ignore: public_member_api_docs
extension CoreStatusVersionSupported on CoreServerStatus {
/// Check if the core/Server version is supported
bool get isSupported => version.startsWith('$coreSupportedVersion.');
}
// ignore: public_member_api_docs // ignore: public_member_api_docs
extension NewsVersionSupported on NewsClient { extension NewsVersionSupported on NewsClient {
/// Check if the news app version is supported by this client /// Check if the news app version is supported by this client
@ -42,7 +48,7 @@ extension NotesVersionSupported on NotesClient {
/// Check if the notes app version is supported by this client /// Check if the notes app version is supported by this client
/// ///
/// Also returns the supported API version number /// Also returns the supported API version number
Future<(bool, int)> isSupported(final CoreServerCapabilities_Ocs_Data capabilities) async => ( (bool, int) isSupported(final CoreServerCapabilities_Ocs_Data capabilities) => (
capabilities.capabilities.notes?.apiVersion capabilities.capabilities.notes?.apiVersion
?.map(Version.parse) ?.map(Version.parse)
.where((final version) => version.major == notesSupportedVersion) .where((final version) => version.major == notesSupportedVersion)

9
packages/nextcloud/test/core_test.dart

@ -17,11 +17,16 @@ Future run(final DockerImage image) async {
}); });
tearDown(() => container.destroy()); tearDown(() => container.destroy());
test('Is supported', () async { test('Is supported from capabilities', () async {
final (supported, _) = await client.core.isSupported((await client.core.getCapabilities()).ocs.data); final (supported, _) = client.core.isSupported((await client.core.getCapabilities()).ocs.data);
expect(supported, isTrue); expect(supported, isTrue);
}); });
test('Is supported from status', () async {
final status = await client.core.getStatus();
expect(status.isSupported, isTrue);
});
test('Get status', () async { test('Get status', () async {
final status = await client.core.getStatus(); final status = await client.core.getStatus();
expect(status.installed, true); expect(status.installed, true);

2
packages/nextcloud/test/notes_test.dart

@ -18,7 +18,7 @@ Future run(final DockerImage image) async {
tearDown(() => container.destroy()); tearDown(() => container.destroy());
test('Is supported', () async { test('Is supported', () async {
final (supported, _) = await client.notes.isSupported((await client.core.getCapabilities()).ocs.data); final (supported, _) = client.notes.isSupported((await client.core.getCapabilities()).ocs.data);
expect(supported, isTrue); expect(supported, isTrue);
}); });

Loading…
Cancel
Save