Browse Source

Merge pull request #291 from provokateurin/feature/qr-code-login

Add QR-Code login
pull/473/head
Kate 1 year ago committed by GitHub
parent
commit
c558290f0f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      packages/app/android/app/src/main/AndroidManifest.xml
  2. 1
      packages/app/linux/flutter/generated_plugins.cmake
  3. 200
      packages/app/pubspec.lock
  4. 4
      packages/neon/neon/lib/l10n/en.arb
  5. 18
      packages/neon/neon/lib/l10n/localizations.dart
  6. 8
      packages/neon/neon/lib/l10n/localizations_en.dart
  7. 2
      packages/neon/neon/lib/models.dart
  8. 3
      packages/neon/neon/lib/src/blocs/accounts.dart
  9. 123
      packages/neon/neon/lib/src/models/account.dart
  10. 58
      packages/neon/neon/lib/src/pages/login.dart
  11. 35
      packages/neon/neon/lib/src/pages/login_check_server_status.dart
  12. 5
      packages/neon/neon/lib/src/pages/login_flow.dart
  13. 60
      packages/neon/neon/lib/src/pages/login_qrcode.dart
  14. 4
      packages/neon/neon/lib/src/pages/settings.dart
  15. 221
      packages/neon/neon/lib/src/router.dart
  16. 160
      packages/neon/neon/lib/src/router.g.dart
  17. 2
      packages/neon/neon/lib/src/utils/exceptions.dart
  18. 14
      packages/neon/neon/lib/src/widgets/exception.dart
  19. 1
      packages/neon/neon/pubspec.yaml
  20. 25
      packages/neon/neon/test/account_test.dart

7
packages/app/android/app/src/main/AndroidManifest.xml

@ -28,6 +28,13 @@
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nc" android:host="login" />
</intent-filter>
</activity> </activity>
<receiver android:enabled="true" android:name=".EmbeddedDistributor" android:exported="false"> <receiver android:enabled="true" android:name=".EmbeddedDistributor" android:exported="false">
<intent-filter> <intent-filter>

1
packages/app/linux/flutter/generated_plugins.cmake

@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_zxing
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)

200
packages/app/pubspec.lock

@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
ansi_styles:
dependency: transitive
description:
name: ansi_styles
sha256: "9c656cc12b3c27b17dd982b2cc5c0cfdfbdabd7bc8f3ae5e8542d9867b47ce8a"
url: "https://pub.dev"
source: hosted
version: "0.3.2+1"
archive: archive:
dependency: transitive dependency: transitive
description: description:
@ -65,6 +73,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.6.1" version: "8.6.1"
camera:
dependency: transitive
description:
name: camera
sha256: ebebead3d5ec3d148249331d751d462d7e8c98102b8830a9b45ec96a2bd4333f
url: "https://pub.dev"
source: hosted
version: "0.10.5+2"
camera_android:
dependency: transitive
description:
name: camera_android
sha256: f43d07f9d7228ea1ca87d22e30881bd68da4b78484a1fbd1f1408b412a41cefb
url: "https://pub.dev"
source: hosted
version: "0.10.8+3"
camera_avfoundation:
dependency: transitive
description:
name: camera_avfoundation
sha256: "1a416e452b30955b392f4efbf23291d3f2ba3660a85e1628859eb62d2a2bab26"
url: "https://pub.dev"
source: hosted
version: "0.9.13+2"
camera_platform_interface:
dependency: transitive
description:
name: camera_platform_interface
sha256: "60fa0bb62a4f3bf3a7c413e31e4cd01b69c779ccc8e4668904a24581b86c316b"
url: "https://pub.dev"
source: hosted
version: "2.5.1"
camera_web:
dependency: transitive
description:
name: camera_web
sha256: bcbd775fb3a9d51cc3ece899d54ad66f6306410556bac5759f78e13f9228841f
url: "https://pub.dev"
source: hosted
version: "0.3.1+4"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@ -73,6 +121,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.3.0"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306
url: "https://pub.dev"
source: hosted
version: "1.3.1"
cli_launcher:
dependency: transitive
description:
name: cli_launcher
sha256: "5e7e0282b79e8642edd6510ee468ae2976d847a0a29b3916e85f5fa1bfe24005"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: b8db3080e59b2503ca9e7922c3df2072cf13992354d5e944074ffa836fba43b7
url: "https://pub.dev"
source: hosted
version: "0.4.0"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@ -89,6 +161,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.1" version: "1.17.1"
conventional_commit:
dependency: transitive
description:
name: conventional_commit
sha256: dec15ad1118f029c618651a4359eb9135d8b88f761aa24e4016d061cd45948f2
url: "https://pub.dev"
source: hosted
version: "0.6.0+1"
convert: convert:
dependency: transitive dependency: transitive
description: description:
@ -344,11 +424,27 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_zxing:
dependency: transitive
description:
name: flutter_zxing
sha256: b25efe5ac91fe7a51aa8bfea7aca7435285b911b8758b7da38b7f037bad62c35
url: "https://pub.dev"
source: hosted
version: "1.1.2"
fuchsia_remote_debug_protocol: fuchsia_remote_debug_protocol:
dependency: transitive dependency: transitive
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
go_router: go_router:
dependency: transitive dependency: transitive
description: description:
@ -357,6 +453,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.0.3" version: "8.0.3"
graphs:
dependency: transitive
description:
name: graphs
sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19
url: "https://pub.dev"
source: hosted
version: "2.3.1"
html: html:
dependency: transitive dependency: transitive
description: description:
@ -474,6 +578,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.18.0" version: "0.18.0"
io:
dependency: transitive
description:
name: io
sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e"
url: "https://pub.dev"
source: hosted
version: "1.0.4"
js: js:
dependency: transitive dependency: transitive
description: description:
@ -538,6 +650,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.7296" version: "7.0.7296"
melos:
dependency: transitive
description:
name: melos
sha256: ccbb6ecd8bb3f08ae8f9ce22920d816bff325a98940c845eda0257cd395503ac
url: "https://pub.dev"
source: hosted
version: "3.1.0"
menu_base: menu_base:
dependency: transitive dependency: transitive
description: description:
@ -562,6 +682,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.4" version: "1.0.4"
mustache_template:
dependency: transitive
description:
name: mustache_template
sha256: a46e26f91445bfb0b60519be280555b06792460b27b19e2b19ad5b9740df5d1c
url: "https://pub.dev"
source: hosted
version: "2.0.0"
neon: neon:
dependency: "direct main" dependency: "direct main"
description: description:
@ -789,6 +917,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.7.3" version: "3.7.3"
pool:
dependency: transitive
description:
name: pool
sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a"
url: "https://pub.dev"
source: hosted
version: "1.5.1"
process: process:
dependency: transitive dependency: transitive
description: description:
@ -797,6 +933,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.4" version: "4.2.4"
prompts:
dependency: transitive
description:
name: prompts
sha256: "3773b845e85a849f01e793c4fc18a45d52d7783b4cb6c0569fad19f9d0a774a1"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
provider: provider:
dependency: transitive dependency: transitive
description: description:
@ -805,6 +949,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.5" version: "6.0.5"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
pub_updater:
dependency: transitive
description:
name: pub_updater
sha256: "42890302ab2672adf567dc2b20e55b4ecc29d7e19c63b6b98143ab68dd717d3a"
url: "https://pub.dev"
source: hosted
version: "0.2.4"
pubspec:
dependency: transitive
description:
name: pubspec
sha256: f534a50a2b4d48dc3bc0ec147c8bd7c304280fff23b153f3f11803c4d49d927e
url: "https://pub.dev"
source: hosted
version: "2.3.0"
queue: queue:
dependency: transitive dependency: transitive
description: description:
@ -845,6 +1013,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.4" version: "1.0.4"
quiver:
dependency: transitive
description:
name: quiver
sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47
url: "https://pub.dev"
source: hosted
version: "3.2.1"
rxdart: rxdart:
dependency: transitive dependency: transitive
description: description:
@ -1009,6 +1185,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "2.1.1"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
string_scanner: string_scanner:
dependency: transitive dependency: transitive
description: description:
@ -1105,6 +1289,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.2" version: "2.2.2"
uri:
dependency: transitive
description:
name: uri
sha256: "889eea21e953187c6099802b7b4cf5219ba8f3518f604a1033064d45b1b8268a"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
url_launcher: url_launcher:
dependency: transitive dependency: transitive
description: description:
@ -1354,6 +1546,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "3.1.2"
yaml_edit:
dependency: transitive
description:
name: yaml_edit
sha256: "1579d4a0340a83cf9e4d580ea51a16329c916973bffd5bd4b45e911b25d46bfd"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
sdks: sdks:
dart: ">=3.0.0 <4.0.0" dart: ">=3.0.0 <4.0.0"
flutter: ">=3.10.4" flutter: ">=3.10.4"

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

@ -10,7 +10,8 @@
"loginOpenAgain": "Open again", "loginOpenAgain": "Open again",
"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", "loginUsingQrcode": "Login using a QR code",
"loginUsingServerAddress": "Login using the server address",
"loginCheckingServerVersion": "Checking server version", "loginCheckingServerVersion": "Checking server version",
"loginSupportedServerVersion": "Supported server version: {version}", "loginSupportedServerVersion": "Supported server version: {version}",
"@loginSupportedServerVersion": { "@loginSupportedServerVersion": {
@ -66,6 +67,7 @@
}, },
"errorEmptyField": "This field can not be empty", "errorEmptyField": "This field can not be empty",
"errorInvalidURL": "Invalid URL provided", "errorInvalidURL": "Invalid URL provided",
"errorInvalidQrcode": "Invalid QR-Code provided",
"actionYes": "Yes", "actionYes": "Yes",
"actionNo": "No", "actionNo": "No",
"actionClose": "Close", "actionClose": "Close",

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

@ -119,11 +119,17 @@ abstract class AppLocalizations {
/// **'works with'** /// **'works with'**
String get loginWorksWith; String get loginWorksWith;
/// No description provided for @loginRestart. /// No description provided for @loginUsingQrcode.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Restart login'** /// **'Login using a QR code'**
String get loginRestart; String get loginUsingQrcode;
/// No description provided for @loginUsingServerAddress.
///
/// In en, this message translates to:
/// **'Login using the server address'**
String get loginUsingServerAddress;
/// No description provided for @loginCheckingServerVersion. /// No description provided for @loginCheckingServerVersion.
/// ///
@ -245,6 +251,12 @@ abstract class AppLocalizations {
/// **'Invalid URL provided'** /// **'Invalid URL provided'**
String get errorInvalidURL; String get errorInvalidURL;
/// No description provided for @errorInvalidQrcode.
///
/// In en, this message translates to:
/// **'Invalid QR-Code provided'**
String get errorInvalidQrcode;
/// No description provided for @actionYes. /// No description provided for @actionYes.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

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

@ -36,7 +36,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get loginWorksWith => 'works with'; String get loginWorksWith => 'works with';
@override @override
String get loginRestart => 'Restart login'; String get loginUsingQrcode => 'Login using a QR code';
@override
String get loginUsingServerAddress => 'Login using the server address';
@override @override
String get loginCheckingServerVersion => 'Checking server version'; String get loginCheckingServerVersion => 'Checking server version';
@ -111,6 +114,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get errorInvalidURL => 'Invalid URL provided'; String get errorInvalidURL => 'Invalid URL provided';
@override
String get errorInvalidQrcode => 'Invalid QR-Code provided';
@override @override
String get actionYes => 'Yes'; String get actionYes => 'Yes';

2
packages/neon/neon/lib/models.dart

@ -1,3 +1,3 @@
export 'package:neon/src/models/account.dart'; export 'package:neon/src/models/account.dart' hide Credentials, LoginQrcode;
export 'package:neon/src/models/app_implementation.dart'; export 'package:neon/src/models/app_implementation.dart';
export 'package:neon/src/models/notifications_interface.dart'; export 'package:neon/src/models/notifications_interface.dart';

3
packages/neon/neon/lib/src/blocs/accounts.dart

@ -207,6 +207,9 @@ class AccountsBloc extends Bloc implements AccountsBlocEvents, AccountsBlocState
return aa; return aa;
} }
/// Whether accounts are logged in.
bool get hasAccounts => activeAccount.value != null;
/// The options for the [activeAccount]. /// The options for the [activeAccount].
/// ///
/// Convenience method for [getOptionsFor] with the currently active account. /// Convenience method for [getOptionsFor] with the currently active account.

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

@ -2,15 +2,29 @@ import 'dart:convert';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:meta/meta.dart';
import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/nextcloud.dart';
part 'account.g.dart'; part 'account.g.dart';
/// Credentials interface
@internal
@immutable
abstract interface class Credentials {
/// Url of the server
abstract final String serverURL;
/// Username
abstract final String username;
/// Password
abstract final String? password;
}
@JsonSerializable() @JsonSerializable()
@immutable @immutable
class Account { class Account implements Credentials {
Account({ Account({
required this.serverURL, required this.serverURL,
required this.loginName, required this.loginName,
@ -29,9 +43,12 @@ class Account {
factory Account.fromJson(final Map<String, dynamic> json) => _$AccountFromJson(json); factory Account.fromJson(final Map<String, dynamic> json) => _$AccountFromJson(json);
Map<String, dynamic> toJson() => _$AccountToJson(this); Map<String, dynamic> toJson() => _$AccountToJson(this);
@override
final String serverURL; final String serverURL;
final String loginName; final String loginName;
@override
final String username; final String username;
@override
final String? password; final String? password;
final String? userAgent; final String? userAgent;
@ -76,3 +93,105 @@ extension AccountFind on Iterable<Account> {
Account? tryFind(final String? accountID) => firstWhereOrNull((final account) => account.id == accountID); Account? tryFind(final String? accountID) => firstWhereOrNull((final account) => account.id == accountID);
Account find(final String accountID) => firstWhere((final account) => account.id == accountID); Account find(final String accountID) => firstWhere((final account) => account.id == accountID);
} }
/// Qrcode Login credentials.
///
/// The Credentials as provided by the server when manually creating an app
/// password.
@internal
@immutable
class LoginQrcode implements Credentials {
/// Creates a new LoginQrcode object.
@visibleForTesting
const LoginQrcode({
required this.serverURL,
required this.username,
required this.password,
});
@override
final String serverURL;
@override
final String username;
@override
final String password;
/// Pattern matching the full Qrcode content.
static final _loginQrcodeUrlRegex = RegExp(r'^nc://login/user:(.*)&password:(.*)&server:(.*)$');
/// Pattern matching the path part of the Qrcode.
///
/// This is used when launching the app through an intent.
static final _loginQrcodePathRegex = RegExp(r'^/user:(.*)&password:(.*)&server:(.*)$');
/// Creates a new `LoginQrcode` object by parsing a url string.
///
/// If the [url] string is not valid as a LoginQrcode a [FormatException] is
/// thrown.
///
/// Example:
/// ```dart
/// final loginQrcode =
/// LoginQrcode.parse('nc://login/user:JohnDoe&password:super_secret&server:example.com');
/// print(loginQrcode.serverURL); // JohnDoe
/// print(loginQrcode.username); // super_secret
/// print(loginQrcode.password); // example.com
///
/// LoginQrcode.parse('::Not valid LoginQrcode::'); // Throws FormatException.
/// ```
static LoginQrcode parse(final String url) {
for (final regex in [_loginQrcodeUrlRegex, _loginQrcodePathRegex]) {
final matches = regex.allMatches(url);
if (matches.isEmpty) {
continue;
}
final match = matches.single;
if (match.groupCount != 3) {
continue;
}
return LoginQrcode(
serverURL: match.group(3)!,
username: match.group(1)!,
password: match.group(2)!,
);
}
throw const FormatException();
}
/// Creates a new `LoginQrcode` object by parsing a url string.
///
/// Returns `null` if the [url] string is not valid as a LoginQrcode.
///
/// Example:
/// ```dart
/// final loginQrcode =
/// LoginQrcode.parse('nc://login/user:JohnDoe&password:super_secret&server:example.com');
/// print(loginQrcode.serverURL); // JohnDoe
/// print(loginQrcode.username); // super_secret
/// print(loginQrcode.password); // example.com
///
/// final notLoginQrcode = LoginQrcode.tryParse('::Not valid LoginQrcode::');
/// print(notLoginQrcode); // null
/// ```
static LoginQrcode? tryParse(final String url) {
try {
return parse(url);
} on FormatException {
return null;
}
}
@override
bool operator ==(final Object other) =>
other is LoginQrcode && other.serverURL == serverURL && other.username == username && other.password == password;
@override
int get hashCode => Object.hashAll([
serverURL,
username,
password,
]);
}

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

@ -1,19 +1,18 @@
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/platform/platform.dart';
import 'package:neon/src/router.dart'; import 'package:neon/src/router.dart';
import 'package:neon/src/theme/branding.dart'; import 'package:neon/src/theme/branding.dart';
import 'package:neon/src/theme/dialog.dart'; import 'package:neon/src/theme/dialog.dart';
import 'package:neon/src/utils/validators.dart'; import 'package:neon/src/utils/validators.dart';
import 'package:neon/src/widgets/nextcloud_logo.dart'; import 'package:neon/src/widgets/nextcloud_logo.dart';
import 'package:provider/provider.dart';
class LoginPage extends StatefulWidget { class LoginPage extends StatefulWidget {
const LoginPage({ const LoginPage({
this.serverURL,
super.key, super.key,
}); });
final String? serverURL;
@override @override
_LoginPageState createState() => _LoginPageState(); _LoginPageState createState() => _LoginPageState();
} }
@ -25,12 +24,6 @@ class _LoginPageState extends State<LoginPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (widget.serverURL != null) {
WidgetsBinding.instance.addPostFrameCallback((final _) async {
await _beginLoginFlow(widget.serverURL!);
});
}
} }
@override @override
@ -39,23 +32,15 @@ class _LoginPageState extends State<LoginPage> {
super.dispose(); super.dispose();
} }
Future _beginLoginFlow(final String serverURL) async {
final result = await LoginCheckServerStatusRoute(serverURL: serverURL).push<bool>(context);
if ((result ?? false) && mounted) {
// This needs be done, otherwise the context is dirty after returning from the previously pushed route
WidgetsBinding.instance.addPostFrameCallback((final _) async {
await LoginFlowRoute(serverURL: serverURL).push(context);
});
}
}
@override @override
Widget build(final BuildContext context) { Widget build(final BuildContext context) {
final branding = Branding.of(context); final branding = Branding.of(context);
final platform = Provider.of<NeonPlatform>(context, listen: false);
return Scaffold( return Scaffold(
resizeToAvoidBottomInset: true, resizeToAvoidBottomInset: true,
appBar: AppBar( appBar: AppBar(
leading: Navigator.of(context).canPop() ? const CloseButton() : null, leading: Navigator.canPop(context) ? const CloseButton() : null,
), ),
body: Center( body: Center(
child: ConstrainedBox( child: ConstrainedBox(
@ -72,7 +57,7 @@ class _LoginPageState extends State<LoginPage> {
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox( const SizedBox(
height: 30, height: 20,
), ),
if (branding.showLoginWithNextcloud) ...[ if (branding.showLoginWithNextcloud) ...[
Text(AppLocalizations.of(context).loginWorksWith), Text(AppLocalizations.of(context).loginWorksWith),
@ -81,6 +66,32 @@ class _LoginPageState extends State<LoginPage> {
), ),
], ],
const NextcloudLogo(), const NextcloudLogo(),
const SizedBox(
height: 40,
),
if (platform.canUseCamera) ...[
ExcludeSemantics(
child: Center(
child: Text(AppLocalizations.of(context).loginUsingQrcode),
),
),
IconButton(
tooltip: AppLocalizations.of(context).loginUsingQrcode,
icon: const Icon(
Icons.qr_code_scanner,
size: 50,
),
onPressed: () => const LoginQrcodeRoute().go(context),
),
const SizedBox(
height: 20,
),
ExcludeSemantics(
child: Center(
child: Text(AppLocalizations.of(context).loginUsingServerAddress),
),
),
],
Form( Form(
key: _formKey, key: _formKey,
child: TextFormField( child: TextFormField(
@ -89,11 +100,10 @@ class _LoginPageState extends State<LoginPage> {
hintText: 'https://...', hintText: 'https://...',
), ),
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
initialValue: widget.serverURL,
validator: (final input) => validateHttpUrl(context, input), validator: (final input) => validateHttpUrl(context, input),
onFieldSubmitted: (final input) async { onFieldSubmitted: (final input) {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
await _beginLoginFlow(input); LoginCheckServerStatusRoute(serverUrl: input).go(context);
} else { } else {
_focusNode.requestFocus(); _focusNode.requestFocus();
} }

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

@ -3,6 +3,7 @@ import 'package:neon/l10n/localizations.dart';
import 'package:neon/src/bloc/result.dart'; import 'package:neon/src/bloc/result.dart';
import 'package:neon/src/bloc/result_builder.dart'; import 'package:neon/src/bloc/result_builder.dart';
import 'package:neon/src/blocs/login_check_server_status.dart'; import 'package:neon/src/blocs/login_check_server_status.dart';
import 'package:neon/src/router.dart';
import 'package:neon/src/theme/dialog.dart'; import 'package:neon/src/theme/dialog.dart';
import 'package:neon/src/widgets/exception.dart'; import 'package:neon/src/widgets/exception.dart';
import 'package:neon/src/widgets/linear_progress_indicator.dart'; import 'package:neon/src/widgets/linear_progress_indicator.dart';
@ -13,9 +14,19 @@ class LoginCheckServerStatusPage extends StatefulWidget {
const LoginCheckServerStatusPage({ const LoginCheckServerStatusPage({
required this.serverURL, required this.serverURL,
super.key, super.key,
}) : loginName = null,
password = null;
const LoginCheckServerStatusPage.withCredentials({
required this.serverURL,
required String this.loginName,
required String this.password,
super.key,
}); });
final String serverURL; final String serverURL;
final String? loginName;
final String? password;
@override @override
State<LoginCheckServerStatusPage> createState() => _LoginCheckServerStatusPageState(); State<LoginCheckServerStatusPage> createState() => _LoginCheckServerStatusPageState();
@ -47,7 +58,10 @@ class _LoginCheckServerStatusPageState extends State<LoginCheckServerStatusPage>
constraints: NeonDialogTheme.of(context).constraints, constraints: NeonDialogTheme.of(context).constraints,
child: ResultBuilder.behaviorSubject( child: ResultBuilder.behaviorSubject(
stream: bloc.state, stream: bloc.state,
builder: (final context, final state) => Column( builder: (final context, final state) {
final success = state.hasData && state.requireData.isSupported && !state.requireData.maintenance;
return Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
NeonLinearProgressIndicator( NeonLinearProgressIndicator(
@ -62,20 +76,31 @@ class _LoginCheckServerStatusPageState extends State<LoginCheckServerStatusPage>
Align( Align(
alignment: Alignment.bottomRight, alignment: Alignment.bottomRight,
child: ElevatedButton( child: ElevatedButton(
onPressed: state.hasData && state.requireData.isSupported && !state.requireData.maintenance onPressed: success ? _onContinue : null,
? () => Navigator.of(context).pop(true)
: null,
child: Text(AppLocalizations.of(context).actionContinue), child: Text(AppLocalizations.of(context).actionContinue),
), ),
), ),
], ],
), );
},
), ),
), ),
), ),
), ),
); );
void _onContinue() {
if (widget.loginName != null) {
LoginCheckAccountRoute(
serverUrl: widget.serverURL,
loginName: widget.loginName!,
password: widget.password!,
).pushReplacement(context);
} else {
LoginFlowRoute(serverUrl: widget.serverURL).pushReplacement(context);
}
}
Widget _buildServerVersionTile(final Result<CoreServerStatus> result) { Widget _buildServerVersionTile(final Result<CoreServerStatus> result) {
if (!result.hasData) { if (!result.hasData) {
return NeonValidationTile( return NeonValidationTile(

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

@ -39,7 +39,7 @@ class _LoginFlowPageState extends State<LoginFlowPage> {
bloc.result.listen((final result) { bloc.result.listen((final result) {
LoginCheckAccountRoute( LoginCheckAccountRoute(
serverURL: result.server, serverUrl: result.server,
loginName: result.loginName, loginName: result.loginName,
password: result.appPassword, password: result.appPassword,
).pushReplacement(context); ).pushReplacement(context);
@ -56,6 +56,8 @@ class _LoginFlowPageState extends State<LoginFlowPage> {
Widget build(final BuildContext context) => Scaffold( Widget build(final BuildContext context) => Scaffold(
appBar: AppBar(), appBar: AppBar(),
body: Center( body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: ResultBuilder.behaviorSubject( child: ResultBuilder.behaviorSubject(
stream: bloc.init, stream: bloc.init,
builder: (final context, final init) => Column( builder: (final context, final init) => Column(
@ -82,5 +84,6 @@ class _LoginFlowPageState extends State<LoginFlowPage> {
), ),
), ),
), ),
),
); );
} }

60
packages/neon/neon/lib/src/pages/login_qrcode.dart

@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter_zxing/flutter_zxing.dart';
import 'package:neon/src/models/account.dart';
import 'package:neon/src/router.dart';
import 'package:neon/src/utils/exceptions.dart';
import 'package:neon/src/widgets/exception.dart';
class LoginQrcodePage extends StatefulWidget {
const LoginQrcodePage({
super.key,
});
@override
State<LoginQrcodePage> createState() => _LoginQrcodePageState();
}
class _LoginQrcodePageState extends State<LoginQrcodePage> {
String? _lastErrorURL;
@override
Widget build(final BuildContext context) => Scaffold(
appBar: AppBar(),
body: ReaderWidget(
codeFormat: Format.qrCode,
showGallery: false,
showToggleCamera: false,
showScannerOverlay: false,
tryHarder: true,
cropPercent: 0,
scanDelaySuccess: const Duration(seconds: 3),
onScan: (final code) async {
String? url;
try {
url = code.text;
if (url == null) {
throw InvalidQrcodeException();
}
final match = LoginQrcode.tryParse(url);
if (match == null) {
throw InvalidQrcodeException();
}
LoginCheckServerStatusRoute.withCredentials(
serverUrl: match.serverURL,
loginName: match.username,
password: match.password,
).pushReplacement(context);
} catch (e, s) {
if (_lastErrorURL != url) {
debugPrint(e.toString());
debugPrint(s.toString());
_lastErrorURL = url;
NeonException.showSnackbar(context, e);
}
}
},
),
);
}

4
packages/neon/neon/lib/src/pages/settings.dart

@ -208,9 +208,7 @@ class _SettingsPageState extends State<SettingsPage> {
], ],
CustomSettingsTile( CustomSettingsTile(
title: ElevatedButton.icon( title: ElevatedButton.icon(
onPressed: () { onPressed: () async => const LoginRoute().push(context),
const AddAccountRoute().go(context);
},
icon: Icon(MdiIcons.accountPlus), icon: Icon(MdiIcons.accountPlus),
label: Text(AppLocalizations.of(context).globalOptionsAccountsAdd), label: Text(AppLocalizations.of(context).globalOptionsAccountsAdd),
), ),

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

@ -1,3 +1,7 @@
// ignore_for_file: unnecessary_overrides
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -11,6 +15,7 @@ import 'package:neon/src/pages/login.dart';
import 'package:neon/src/pages/login_check_account.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_check_server_status.dart';
import 'package:neon/src/pages/login_flow.dart'; import 'package:neon/src/pages/login_flow.dart';
import 'package:neon/src/pages/login_qrcode.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';
@ -29,10 +34,17 @@ class AppRouter extends GoRouter {
navigatorKey: navigatorKey, navigatorKey: navigatorKey,
initialLocation: const HomeRoute().location, initialLocation: const HomeRoute().location,
redirect: (final context, final state) { redirect: (final context, final state) {
final account = accountsBloc.activeAccount.valueOrNull; final loginQrcode = LoginQrcode.tryParse(state.location);
if (loginQrcode != null) {
return LoginCheckServerStatusRoute.withCredentials(
serverUrl: loginQrcode.serverURL,
loginName: loginQrcode.username,
password: loginQrcode.password,
).location;
}
// redirect to loginscreen when no account is logged in // redirect to loginscreen when no account is logged in
if (account == null && !state.location.startsWith(const LoginRoute().location)) { if (!accountsBloc.hasAccounts && !state.location.startsWith(const LoginRoute().location)) {
return const LoginRoute().location; return const LoginRoute().location;
} }
@ -74,9 +86,23 @@ class AccountSettingsRoute extends GoRouteData {
path: 'apps/:appid', path: 'apps/:appid',
name: 'NextcloudAppSettings', name: 'NextcloudAppSettings',
), ),
TypedGoRoute<AddAccountRoute>( TypedGoRoute<_AddAccountRoute>(
path: 'account/add', path: 'account/add',
name: 'addAccount', name: 'addAccount',
routes: [
TypedGoRoute<_AddAccountFlowRoute>(
path: 'flow',
),
TypedGoRoute<_AddAccountQrcodeRoute>(
path: 'qrcode',
),
TypedGoRoute<_AddAccountCheckServerStatusRoute>(
path: 'check/server',
),
TypedGoRoute<_AddAccountCheckAccountRoute>(
path: 'check/account',
),
],
), ),
TypedGoRoute<AccountSettingsRoute>( TypedGoRoute<AccountSettingsRoute>(
path: 'account/:accountid', path: 'account/:accountid',
@ -104,81 +130,222 @@ class HomeRoute extends GoRouteData {
name: 'login', name: 'login',
routes: [ routes: [
TypedGoRoute<LoginFlowRoute>( TypedGoRoute<LoginFlowRoute>(
path: 'flow/:serverURL', path: 'flow',
name: 'loginFlow', ),
TypedGoRoute<LoginQrcodeRoute>(
path: 'qrcode',
), ),
TypedGoRoute<LoginCheckServerStatusRoute>( TypedGoRoute<LoginCheckServerStatusRoute>(
path: 'check/server/:serverURL', path: 'check/server',
name: 'checkServerStatus',
), ),
TypedGoRoute<LoginCheckAccountRoute>( TypedGoRoute<LoginCheckAccountRoute>(
path: 'check/account/:serverURL/:loginName/:password', path: 'check/account',
name: 'checkAccount',
), ),
], ],
) )
@immutable @immutable
class LoginRoute extends GoRouteData { class LoginRoute extends GoRouteData {
const LoginRoute({this.serverURL}); const LoginRoute();
final String? serverURL; @override
Widget build(final BuildContext context, final GoRouterState state) => const LoginPage();
@override @override
Widget build(final BuildContext context, final GoRouterState state) => LoginPage(serverURL: serverURL); FutureOr<String?> redirect(final BuildContext context, final GoRouterState state) {
final hasAccounts = Provider.of<AccountsBloc>(context, listen: false).hasAccounts;
if (state.fullPath == location && hasAccounts) {
return const _AddAccountRoute().location;
}
return null;
}
} }
@immutable @immutable
class LoginFlowRoute extends GoRouteData { class LoginFlowRoute extends GoRouteData {
const LoginFlowRoute({ const LoginFlowRoute({
required this.serverURL, required this.serverUrl,
}); });
final String serverURL; final String serverUrl;
@override @override
Widget build(final BuildContext context, final GoRouterState state) => LoginFlowPage(serverURL: serverURL); Widget build(final BuildContext context, final GoRouterState state) => LoginFlowPage(serverURL: serverUrl);
@override
FutureOr<String?> redirect(final BuildContext context, final GoRouterState state) {
final hasAccounts = Provider.of<AccountsBloc>(context, listen: false).hasAccounts;
if (state.fullPath == location && hasAccounts) {
return _AddAccountFlowRoute(serverUrl: serverUrl).location;
}
return null;
}
}
@immutable
class LoginQrcodeRoute extends GoRouteData {
const LoginQrcodeRoute();
@override
Widget build(final BuildContext context, final GoRouterState state) => const LoginQrcodePage();
@override
FutureOr<String?> redirect(final BuildContext context, final GoRouterState state) {
final hasAccounts = Provider.of<AccountsBloc>(context, listen: false).hasAccounts;
if (state.fullPath == location && hasAccounts) {
return const _AddAccountQrcodeRoute().location;
}
return null;
}
} }
@immutable @immutable
class LoginCheckServerStatusRoute extends GoRouteData { class LoginCheckServerStatusRoute extends GoRouteData {
const LoginCheckServerStatusRoute({ const LoginCheckServerStatusRoute({
required this.serverURL, required this.serverUrl,
}); }) : loginName = null,
password = null;
const LoginCheckServerStatusRoute.withCredentials({
required this.serverUrl,
required String this.loginName,
required String this.password,
}) : assert(!kIsWeb, 'Might leak the password to the browser history');
final String serverURL; final String serverUrl;
final String? loginName;
final String? password;
@override @override
Widget build(final BuildContext context, final GoRouterState state) => LoginCheckServerStatusPage( Widget build(final BuildContext context, final GoRouterState state) {
serverURL: serverURL, if (loginName != null && password != null) {
return LoginCheckServerStatusPage.withCredentials(
serverURL: serverUrl,
loginName: loginName!,
password: password!,
); );
}
return LoginCheckServerStatusPage(
serverURL: serverUrl,
);
}
@override
FutureOr<String?> redirect(final BuildContext context, final GoRouterState state) {
final hasAccounts = Provider.of<AccountsBloc>(context, listen: false).hasAccounts;
if (state.fullPath == location && hasAccounts) {
if (loginName != null && password != null) {
return _AddAccountCheckServerStatusRoute.withCredentials(
serverUrl: serverUrl,
loginName: loginName!,
password: password!,
).location;
}
return _AddAccountCheckServerStatusRoute(
serverUrl: serverUrl,
).location;
}
return null;
}
} }
@immutable @immutable
class LoginCheckAccountRoute extends GoRouteData { class LoginCheckAccountRoute extends GoRouteData {
const LoginCheckAccountRoute({ const LoginCheckAccountRoute({
required this.serverURL, required this.serverUrl,
required this.loginName, required this.loginName,
required this.password, required this.password,
}); }) : assert(!kIsWeb, 'Might leak the password to the browser history');
final String serverURL; final String serverUrl;
final String loginName; final String loginName;
final String password; final String password;
@override @override
Widget build(final BuildContext context, final GoRouterState state) => LoginCheckAccountPage( Widget build(final BuildContext context, final GoRouterState state) => LoginCheckAccountPage(
serverURL: serverURL, serverURL: serverUrl,
loginName: loginName, loginName: loginName,
password: password, password: password,
); );
@override
FutureOr<String?> redirect(final BuildContext context, final GoRouterState state) {
final hasAccounts = Provider.of<AccountsBloc>(context, listen: false).hasAccounts;
if (state.fullPath == location && hasAccounts) {
return _AddAccountCheckAccountRoute(
serverUrl: serverUrl,
loginName: loginName,
password: password,
).location;
}
return null;
}
} }
@immutable @immutable
class AddAccountRoute extends GoRouteData { class _AddAccountRoute extends LoginRoute {
const AddAccountRoute(); const _AddAccountRoute();
}
@immutable
class _AddAccountFlowRoute extends LoginFlowRoute {
const _AddAccountFlowRoute({
required super.serverUrl,
});
@override @override
Widget build(final BuildContext context, final GoRouterState state) => const LoginPage(); String get serverUrl => super.serverUrl;
}
@immutable
class _AddAccountQrcodeRoute extends LoginQrcodeRoute {
const _AddAccountQrcodeRoute();
}
@immutable
class _AddAccountCheckServerStatusRoute extends LoginCheckServerStatusRoute {
const _AddAccountCheckServerStatusRoute({
required super.serverUrl,
});
const _AddAccountCheckServerStatusRoute.withCredentials({
required super.serverUrl,
required super.loginName,
required super.password,
}) : super.withCredentials();
@override
String get serverUrl => super.serverUrl;
@override
String? get loginName => super.loginName;
@override
String? get password => super.password;
}
@immutable
class _AddAccountCheckAccountRoute extends LoginCheckAccountRoute {
const _AddAccountCheckAccountRoute({
required super.serverUrl,
required super.loginName,
required super.password,
});
@override
String get serverUrl => super.serverUrl;
@override
String get loginName => super.loginName;
@override
String get password => super.password;
} }
@immutable @immutable

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

@ -29,7 +29,25 @@ RouteBase get $homeRoute => GoRouteData.$route(
GoRouteData.$route( GoRouteData.$route(
path: 'account/add', path: 'account/add',
name: 'addAccount', name: 'addAccount',
factory: $AddAccountRouteExtension._fromState, factory: $_AddAccountRouteExtension._fromState,
routes: [
GoRouteData.$route(
path: 'flow',
factory: $_AddAccountFlowRouteExtension._fromState,
),
GoRouteData.$route(
path: 'qrcode',
factory: $_AddAccountQrcodeRouteExtension._fromState,
),
GoRouteData.$route(
path: 'check/server',
factory: $_AddAccountCheckServerStatusRouteExtension._fromState,
),
GoRouteData.$route(
path: 'check/account',
factory: $_AddAccountCheckAccountRouteExtension._fromState,
),
],
), ),
GoRouteData.$route( GoRouteData.$route(
path: 'account/:accountid', path: 'account/:accountid',
@ -85,8 +103,8 @@ extension $NextcloudAppSettingsRouteExtension on NextcloudAppSettingsRoute {
void pushReplacement(BuildContext context) => context.pushReplacement(location); void pushReplacement(BuildContext context) => context.pushReplacement(location);
} }
extension $AddAccountRouteExtension on AddAccountRoute { extension $_AddAccountRouteExtension on _AddAccountRoute {
static AddAccountRoute _fromState(GoRouterState state) => const AddAccountRoute(); static _AddAccountRoute _fromState(GoRouterState state) => const _AddAccountRoute();
String get location => GoRouteData.$location( String get location => GoRouteData.$location(
'/settings/account/add', '/settings/account/add',
@ -99,6 +117,81 @@ extension $AddAccountRouteExtension on AddAccountRoute {
void pushReplacement(BuildContext context) => context.pushReplacement(location); void pushReplacement(BuildContext context) => context.pushReplacement(location);
} }
extension $_AddAccountFlowRouteExtension on _AddAccountFlowRoute {
static _AddAccountFlowRoute _fromState(GoRouterState state) => _AddAccountFlowRoute(
serverUrl: state.queryParameters['server-url']!,
);
String get location => GoRouteData.$location(
'/settings/account/add/flow',
queryParams: {
'server-url': 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 $_AddAccountQrcodeRouteExtension on _AddAccountQrcodeRoute {
static _AddAccountQrcodeRoute _fromState(GoRouterState state) => const _AddAccountQrcodeRoute();
String get location => GoRouteData.$location(
'/settings/account/add/qrcode',
);
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 $_AddAccountCheckServerStatusRouteExtension on _AddAccountCheckServerStatusRoute {
static _AddAccountCheckServerStatusRoute _fromState(GoRouterState state) => _AddAccountCheckServerStatusRoute(
serverUrl: state.queryParameters['server-url']!,
);
String get location => GoRouteData.$location(
'/settings/account/add/check/server',
queryParams: {
'server-url': 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 $_AddAccountCheckAccountRouteExtension on _AddAccountCheckAccountRoute {
static _AddAccountCheckAccountRoute _fromState(GoRouterState state) => _AddAccountCheckAccountRoute(
serverUrl: state.queryParameters['server-url']!,
loginName: state.queryParameters['login-name']!,
password: state.queryParameters['password']!,
);
String get location => GoRouteData.$location(
'/settings/account/add/check/account',
queryParams: {
'server-url': serverUrl,
'login-name': loginName,
'password': 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);
}
extension $AccountSettingsRouteExtension on AccountSettingsRoute { extension $AccountSettingsRouteExtension on AccountSettingsRoute {
static AccountSettingsRoute _fromState(GoRouterState state) => AccountSettingsRoute( static AccountSettingsRoute _fromState(GoRouterState state) => AccountSettingsRoute(
accountid: state.pathParameters['accountid']!, accountid: state.pathParameters['accountid']!,
@ -121,33 +214,29 @@ RouteBase get $loginRoute => GoRouteData.$route(
factory: $LoginRouteExtension._fromState, factory: $LoginRouteExtension._fromState,
routes: [ routes: [
GoRouteData.$route( GoRouteData.$route(
path: 'flow/:serverURL', path: 'flow',
name: 'loginFlow',
factory: $LoginFlowRouteExtension._fromState, factory: $LoginFlowRouteExtension._fromState,
), ),
GoRouteData.$route( GoRouteData.$route(
path: 'check/server/:serverURL', path: 'qrcode',
name: 'checkServerStatus', factory: $LoginQrcodeRouteExtension._fromState,
),
GoRouteData.$route(
path: 'check/server',
factory: $LoginCheckServerStatusRouteExtension._fromState, factory: $LoginCheckServerStatusRouteExtension._fromState,
), ),
GoRouteData.$route( GoRouteData.$route(
path: 'check/account/:serverURL/:loginName/:password', path: 'check/account',
name: 'checkAccount',
factory: $LoginCheckAccountRouteExtension._fromState, factory: $LoginCheckAccountRouteExtension._fromState,
), ),
], ],
); );
extension $LoginRouteExtension on LoginRoute { extension $LoginRouteExtension on LoginRoute {
static LoginRoute _fromState(GoRouterState state) => LoginRoute( static LoginRoute _fromState(GoRouterState state) => const LoginRoute();
serverURL: state.queryParameters['server-u-r-l'],
);
String get location => GoRouteData.$location( String get location => GoRouteData.$location(
'/login', '/login',
queryParams: {
if (serverURL != null) 'server-u-r-l': serverURL,
},
); );
void go(BuildContext context) => context.go(location); void go(BuildContext context) => context.go(location);
@ -159,11 +248,28 @@ extension $LoginRouteExtension on LoginRoute {
extension $LoginFlowRouteExtension on LoginFlowRoute { extension $LoginFlowRouteExtension on LoginFlowRoute {
static LoginFlowRoute _fromState(GoRouterState state) => LoginFlowRoute( static LoginFlowRoute _fromState(GoRouterState state) => LoginFlowRoute(
serverURL: state.pathParameters['serverURL']!, serverUrl: state.queryParameters['server-url']!,
); );
String get location => GoRouteData.$location( String get location => GoRouteData.$location(
'/login/flow/${Uri.encodeComponent(serverURL)}', '/login/flow',
queryParams: {
'server-url': 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 $LoginQrcodeRouteExtension on LoginQrcodeRoute {
static LoginQrcodeRoute _fromState(GoRouterState state) => const LoginQrcodeRoute();
String get location => GoRouteData.$location(
'/login/qrcode',
); );
void go(BuildContext context) => context.go(location); void go(BuildContext context) => context.go(location);
@ -175,11 +281,14 @@ extension $LoginFlowRouteExtension on LoginFlowRoute {
extension $LoginCheckServerStatusRouteExtension on LoginCheckServerStatusRoute { extension $LoginCheckServerStatusRouteExtension on LoginCheckServerStatusRoute {
static LoginCheckServerStatusRoute _fromState(GoRouterState state) => LoginCheckServerStatusRoute( static LoginCheckServerStatusRoute _fromState(GoRouterState state) => LoginCheckServerStatusRoute(
serverURL: state.pathParameters['serverURL']!, serverUrl: state.queryParameters['server-url']!,
); );
String get location => GoRouteData.$location( String get location => GoRouteData.$location(
'/login/check/server/${Uri.encodeComponent(serverURL)}', '/login/check/server',
queryParams: {
'server-url': serverUrl,
},
); );
void go(BuildContext context) => context.go(location); void go(BuildContext context) => context.go(location);
@ -191,13 +300,18 @@ extension $LoginCheckServerStatusRouteExtension on LoginCheckServerStatusRoute {
extension $LoginCheckAccountRouteExtension on LoginCheckAccountRoute { extension $LoginCheckAccountRouteExtension on LoginCheckAccountRoute {
static LoginCheckAccountRoute _fromState(GoRouterState state) => LoginCheckAccountRoute( static LoginCheckAccountRoute _fromState(GoRouterState state) => LoginCheckAccountRoute(
serverURL: state.pathParameters['serverURL']!, serverUrl: state.queryParameters['server-url']!,
loginName: state.pathParameters['loginName']!, loginName: state.queryParameters['login-name']!,
password: state.pathParameters['password']!, password: state.queryParameters['password']!,
); );
String get location => GoRouteData.$location( String get location => GoRouteData.$location(
'/login/check/account/${Uri.encodeComponent(serverURL)}/${Uri.encodeComponent(loginName)}/${Uri.encodeComponent(password)}', '/login/check/account',
queryParams: {
'server-url': serverUrl,
'login-name': loginName,
'password': password,
},
); );
void go(BuildContext context) => context.go(location); void go(BuildContext context) => context.go(location);

2
packages/neon/neon/lib/src/utils/exceptions.dart

@ -7,3 +7,5 @@ class MissingPermissionException implements Exception {
} }
class UnableToOpenFileException implements Exception {} class UnableToOpenFileException implements Exception {}
class InvalidQrcodeException implements Exception {}

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

@ -128,6 +128,12 @@ class NeonException extends StatelessWidget {
); );
} }
if (exception is InvalidQrcodeException) {
return _ExceptionDetails(
text: AppLocalizations.of(context).errorInvalidQrcode,
);
}
if (exception is DynamiteApiException) { if (exception is DynamiteApiException) {
if (exception.statusCode == 401) { if (exception.statusCode == 401) {
return _ExceptionDetails( return _ExceptionDetails(
@ -179,9 +185,11 @@ class NeonException extends StatelessWidget {
} }
static void _openLoginPage(final BuildContext context) { static void _openLoginPage(final BuildContext context) {
LoginRoute( unawaited(
serverURL: Provider.of<AccountsBloc>(context, listen: false).activeAccount.value!.serverURL, LoginCheckServerStatusRoute(
).go(context); serverUrl: Provider.of<AccountsBloc>(context, listen: false).activeAccount.value!.serverURL,
).push(context),
);
} }
} }

1
packages/neon/neon/pubspec.yaml

@ -21,6 +21,7 @@ dependencies:
sdk: flutter sdk: flutter
flutter_native_splash: ^2.2.19 flutter_native_splash: ^2.2.19
flutter_svg: ^2.0.5 flutter_svg: ^2.0.5
flutter_zxing: ^1.1.2 # ^1.2.0 downgrades to image ^3.0.0 which breaks our dependencies. See https://github.com/khoren93/flutter_zxing/issues/94
go_router: ^8.0.3 go_router: ^8.0.3
http: ^0.13.6 http: ^0.13.6
intersperse: ^2.0.0 intersperse: ^2.0.0

25
packages/neon/neon/test/account_test.dart

@ -0,0 +1,25 @@
import 'package:neon/src/models/account.dart';
import 'package:test/test.dart';
void main() {
const qrCodePath = '/user:JohnDoe&password:super_secret&server:example.com';
const qrCode = 'nc://login$qrCodePath';
const invalidUrl = '::Not valid LoginQrcode::';
const credentials = LoginQrcode(
serverURL: 'example.com',
username: 'JohnDoe',
password: 'super_secret',
);
group('LoginQrcode', () {
test('parse', () {
expect(LoginQrcode.tryParse(qrCode), equals(credentials));
expect(LoginQrcode.tryParse(qrCodePath), equals(credentials));
expect(LoginQrcode.tryParse(invalidUrl), null);
});
test('equality', () {
expect(credentials, equals(credentials));
});
});
}
Loading…
Cancel
Save