diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 75488237..e9ee5db1 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -3,7 +3,6 @@ import 'package:fluffychat/pages/homeserver_picker.dart'; import 'package:fluffychat/pages/invitation_selection.dart'; import 'package:fluffychat/pages/settings_emotes.dart'; import 'package:fluffychat/pages/settings_multiple_emotes.dart'; -import 'package:fluffychat/pages/sign_up.dart'; import 'package:fluffychat/widgets/layouts/side_view_layout.dart'; import 'package:fluffychat/widgets/layouts/two_column_layout.dart'; import 'package:fluffychat/pages/chat.dart'; @@ -199,16 +198,10 @@ class AppRoutes { buildTransition: _fadeTransition, stackedRoutes: [ VWidget( - path: '/signup', - widget: SignUp(), - buildTransition: _fadeTransition, - stackedRoutes: [ - VWidget( - path: '/login', - widget: Login(), - buildTransition: _fadeTransition, - ), - ]), + path: '/login', + widget: Login(), + buildTransition: _fadeTransition, + ), ], ), ]; diff --git a/lib/pages/homeserver_picker.dart b/lib/pages/homeserver_picker.dart index e021e0e7..4e288222 100644 --- a/lib/pages/homeserver_picker.dart +++ b/lib/pages/homeserver_picker.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:famedlysdk/famedlysdk.dart'; -import 'package:fluffychat/pages/sign_up.dart'; import 'package:fluffychat/pages/views/homeserver_picker_view.dart'; import 'package:fluffychat/utils/famedlysdk_store.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -10,6 +9,7 @@ import 'package:fluffychat/config/setting_keys.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../utils/localized_exception_extension.dart'; import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/utils/platform_infos.dart'; @@ -30,6 +30,16 @@ class HomeserverPickerController extends State { final TextEditingController homeserverController = TextEditingController(text: AppConfig.defaultHomeserver); StreamSubscription _intentDataStreamSubscription; + String error; + Timer _coolDown; + + void setDomain(String domain) { + this.domain = domain; + _coolDown?.cancel(); + if (domain.isNotEmpty) { + _coolDown = Timer(Duration(seconds: 1), checkHomeserverAction); + } + } void _loginWithToken(String token) { if (token?.isEmpty ?? true) return; @@ -39,7 +49,8 @@ class HomeserverPickerController extends State { future: () async { if (Matrix.of(context).client.homeserver == null) { await Matrix.of(context).client.checkHomeserver( - await Store().getItem(SignUpController.ssoHomeserverKey), + await Store() + .getItem(HomeserverPickerController.ssoHomeserverKey), ); } await Matrix.of(context).client.login( @@ -90,9 +101,9 @@ class HomeserverPickerController extends State { /// Starts an analysis of the given homeserver. It uses the current domain and /// makes sure that it is prefixed with https. Then it searches for the /// well-known information and forwards to the login page depending on the - /// login type. For SSO login only the app opens the page and otherwise it - /// forwards to the route `/signup`. + /// login type. void checkHomeserverAction() async { + _coolDown?.cancel(); try { if (domain.isEmpty) throw L10n.of(context).changeTheHomeserver; var homeserver = domain; @@ -101,7 +112,10 @@ class HomeserverPickerController extends State { homeserver = 'https://$homeserver'; } - setState(() => isLoading = true); + setState(() { + error = _rawLoginTypes = registrationSupported = null; + isLoading = true; + }); final wellKnown = await Matrix.of(context).client.checkHomeserver(homeserver); @@ -118,13 +132,8 @@ class HomeserverPickerController extends State { .setItem(SettingKeys.jitsiInstance, jitsi); AppConfig.jitsiInstance = jitsi; } - - VRouter.of(context).push( - AppConfig.enableRegistration ? '/signup' : '/login', - historyState: {'/home': '/signup'}); } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text((e as Object).toLocalizedString(context)))); + setState(() => error = '${(e as Object).toLocalizedString(context)}'); } finally { if (mounted) { setState(() => isLoading = false); @@ -132,9 +141,98 @@ class HomeserverPickerController extends State { } } + Map _rawLoginTypes; + bool registrationSupported; + + List get identityProviders { + if (!ssoLoginSupported) return []; + final rawProviders = _rawLoginTypes.tryGetList('flows').singleWhere( + (flow) => + flow['type'] == AuthenticationTypes.sso)['identity_providers']; + return (rawProviders as List) + .map((json) => IdentityProvider.fromJson(json)) + .toList(); + } + + bool get passwordLoginSupported => + Matrix.of(context) + .client + .supportedLoginTypes + .contains(AuthenticationTypes.password) && + _rawLoginTypes + .tryGetList('flows') + .any((flow) => flow['type'] == AuthenticationTypes.password); + + bool get ssoLoginSupported => + Matrix.of(context) + .client + .supportedLoginTypes + .contains(AuthenticationTypes.sso) && + _rawLoginTypes + .tryGetList('flows') + .any((flow) => flow['type'] == AuthenticationTypes.sso); + + Future> getLoginTypes() async { + _rawLoginTypes ??= await Matrix.of(context).client.request( + RequestType.GET, + '/client/r0/login', + ); + if (registrationSupported == null) { + try { + await Matrix.of(context).client.register(); + registrationSupported = true; + } on MatrixException catch (e) { + registrationSupported = e.requireAdditionalAuthentication ?? false; + } + } + return _rawLoginTypes; + } + + static const String ssoHomeserverKey = 'sso-homeserver'; + + void ssoLoginAction(String id) { + if (kIsWeb) { + // We store the homserver in the local storage instead of a redirect + // parameter because of possible CSRF attacks. + Store().setItem( + ssoHomeserverKey, Matrix.of(context).client.homeserver.toString()); + } + final redirectUrl = kIsWeb + ? html.window.location.href + : AppConfig.appOpenUrlScheme.toLowerCase() + '://sso'; + launch( + '${Matrix.of(context).client.homeserver?.toString()}/_matrix/client/r0/login/sso/redirect/${Uri.encodeComponent(id)}?redirectUrl=${Uri.encodeQueryComponent(redirectUrl)}'); + } + + void signUpAction() => launch( + '${Matrix.of(context).client.homeserver?.toString()}/_matrix/static/client/register'); + + bool _initialized = false; + @override Widget build(BuildContext context) { Matrix.of(context).navigatorContext = context; + if (!_initialized) { + _initialized = true; + checkHomeserverAction(); + } return HomeserverPickerView(this); } } + +class IdentityProvider { + final String id; + final String name; + final String icon; + final String brand; + + IdentityProvider({this.id, this.name, this.icon, this.brand}); + + factory IdentityProvider.fromJson(Map json) => + IdentityProvider( + id: json['id'], + name: json['name'], + icon: json['icon'], + brand: json['brand'], + ); +} diff --git a/lib/pages/sign_up.dart b/lib/pages/sign_up.dart deleted file mode 100644 index e453b654..00000000 --- a/lib/pages/sign_up.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'dart:async'; - -import 'package:famedlysdk/famedlysdk.dart'; -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/pages/views/sign_up_view.dart'; -import 'package:fluffychat/utils/famedlysdk_store.dart'; - -import 'package:fluffychat/widgets/matrix.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:universal_html/html.dart' as html; - -class SignUp extends StatefulWidget { - @override - SignUpController createState() => SignUpController(); -} - -class SignUpController extends State { - Map _rawLoginTypes; - bool registrationSupported; - - List get identityProviders { - if (!ssoLoginSupported) return []; - final rawProviders = _rawLoginTypes.tryGetList('flows').singleWhere( - (flow) => - flow['type'] == AuthenticationTypes.sso)['identity_providers']; - return (rawProviders as List) - .map((json) => IdentityProvider.fromJson(json)) - .toList(); - } - - bool get passwordLoginSupported => - Matrix.of(context) - .client - .supportedLoginTypes - .contains(AuthenticationTypes.password) && - _rawLoginTypes - .tryGetList('flows') - .any((flow) => flow['type'] == AuthenticationTypes.password); - - bool get ssoLoginSupported => - Matrix.of(context) - .client - .supportedLoginTypes - .contains(AuthenticationTypes.sso) && - _rawLoginTypes - .tryGetList('flows') - .any((flow) => flow['type'] == AuthenticationTypes.sso); - - Future> getLoginTypes() async { - _rawLoginTypes ??= await Matrix.of(context).client.request( - RequestType.GET, - '/client/r0/login', - ); - if (registrationSupported == null) { - try { - await Matrix.of(context).client.register(); - registrationSupported = true; - } on MatrixException catch (e) { - registrationSupported = e.requireAdditionalAuthentication ?? false; - } - } - return _rawLoginTypes; - } - - static const String ssoHomeserverKey = 'sso-homeserver'; - - void ssoLoginAction(String id) { - if (kIsWeb) { - // We store the homserver in the local storage instead of a redirect - // parameter because of possible CSRF attacks. - Store().setItem( - ssoHomeserverKey, Matrix.of(context).client.homeserver.toString()); - } - final redirectUrl = kIsWeb - ? html.window.location.href - : AppConfig.appOpenUrlScheme.toLowerCase() + '://sso'; - launch( - '${Matrix.of(context).client.homeserver?.toString()}/_matrix/client/r0/login/sso/redirect/${Uri.encodeComponent(id)}?redirectUrl=${Uri.encodeQueryComponent(redirectUrl)}'); - } - - void signUpAction() => launch( - '${Matrix.of(context).client.homeserver?.toString()}/_matrix/static/client/register'); - - @override - Widget build(BuildContext context) => SignUpView(this); -} - -class IdentityProvider { - final String id; - final String name; - final String icon; - final String brand; - - IdentityProvider({this.id, this.name, this.icon, this.brand}); - - factory IdentityProvider.fromJson(Map json) => - IdentityProvider( - id: json['id'], - name: json['name'], - icon: json['icon'], - brand: json['brand'], - ); -} diff --git a/lib/pages/views/homeserver_picker_view.dart b/lib/pages/views/homeserver_picker_view.dart index 46b7f1bd..59af6fce 100644 --- a/lib/pages/views/homeserver_picker_view.dart +++ b/lib/pages/views/homeserver_picker_view.dart @@ -1,3 +1,7 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:vrouter/vrouter.dart'; + import '../homeserver_picker.dart'; import 'package:fluffychat/widgets/default_app_bar_search_field.dart'; import 'package:fluffychat/widgets/fluffy_banner.dart'; @@ -9,6 +13,9 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../utils/localized_exception_extension.dart'; + +import 'package:famedlysdk/famedlysdk.dart'; class HomeserverPickerView extends StatelessWidget { final HomeserverPickerController controller; @@ -27,79 +34,159 @@ class HomeserverPickerView extends StatelessWidget { searchController: controller.homeserverController, suffix: Icon(Icons.edit_outlined), padding: EdgeInsets.zero, - onChanged: (s) => controller.domain = s, + onChanged: controller.setDomain, readOnly: !AppConfig.allowOtherHomeservers, onSubmit: (_) => controller.checkHomeserverAction(), unfocusOnClear: false, ), elevation: 0, ), - body: SafeArea( - child: ListView( + body: ListView(children: [ + Hero( + tag: 'loginBanner', + child: FluffyBanner(), + ), + controller.isLoading + ? Center(child: CircularProgressIndicator()) + : controller.error != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Text( + controller.error, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + color: Colors.red[900], + ), + ), + ), + ) + : FutureBuilder( + future: controller.getLoginTypes(), + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Text( + snapshot.error.toLocalizedString(context), + textAlign: TextAlign.center, + ), + ); + } + if (!snapshot.hasData) { + return Center(child: CircularProgressIndicator()); + } + return Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (controller.ssoLoginSupported) ...{ + for (final identityProvider + in controller.identityProviders) + OutlinedButton.icon( + onPressed: () => controller + .ssoLoginAction(identityProvider.id), + icon: identityProvider.icon == null + ? Icon(Icons.web_outlined) + : CachedNetworkImage( + imageUrl: Uri.parse( + identityProvider.icon) + .getDownloadLink( + Matrix.of(context).client) + .toString(), + width: 24, + height: 24, + ), + label: Text(L10n.of(context).loginWith( + identityProvider.brand ?? + identityProvider.name ?? + L10n.of(context).singlesignon)), + ), + if (controller.registrationSupported || + controller.passwordLoginSupported) + Row(children: [ + Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.all(12.0), + child: Text(L10n.of(context).or), + ), + Expanded(child: Divider()), + ]), + }, + Row( + children: [ + if (controller.passwordLoginSupported) + Expanded( + child: Container( + height: 64, + child: OutlinedButton.icon( + onPressed: () => VRouter.of(context) + .push('/login'), + icon: Icon(Icons.login_outlined), + label: Text(L10n.of(context).login), + ), + ), + ), + if (controller.registrationSupported && + controller.passwordLoginSupported) + SizedBox(width: 12), + if (controller.registrationSupported) + Expanded( + child: Container( + height: 64, + child: OutlinedButton.icon( + onPressed: controller.signUpAction, + icon: Icon(Icons.add_box_outlined), + label: + Text(L10n.of(context).register), + ), + ), + ), + ], + ), + ] + .map( + (widget) => Container( + height: 64, + padding: EdgeInsets.only(bottom: 12), + child: widget), + ) + .toList(), + ), + ); + }), + ]), + bottomNavigationBar: Material( + elevation: 7, + color: Theme.of(context).scaffoldBackgroundColor, + child: Wrap( + alignment: WrapAlignment.center, children: [ - Hero( - tag: 'loginBanner', - child: FluffyBanner(), - ), - Padding( - padding: const EdgeInsets.all(16.0), + TextButton( + onPressed: () => launch(AppConfig.privacyUrl), child: Text( - AppConfig.applicationWelcomeMessage ?? - L10n.of(context).welcomeText, - textAlign: TextAlign.center, + L10n.of(context).privacy, style: TextStyle( - fontSize: 22, + decoration: TextDecoration.underline, + color: Colors.blueGrey, + ), + ), + ), + TextButton( + onPressed: () => PlatformInfos.showDialog(context), + child: Text( + L10n.of(context).about, + style: TextStyle( + decoration: TextDecoration.underline, + color: Colors.blueGrey, ), ), ), ], ), ), - bottomNavigationBar: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Hero( - tag: 'loginButton', - child: Container( - width: double.infinity, - padding: EdgeInsets.symmetric(horizontal: 12), - child: ElevatedButton( - onPressed: controller.isLoading - ? null - : controller.checkHomeserverAction, - child: controller.isLoading - ? LinearProgressIndicator() - : Text(L10n.of(context).connect), - ), - ), - ), - Wrap( - alignment: WrapAlignment.center, - children: [ - TextButton( - onPressed: () => launch(AppConfig.privacyUrl), - child: Text( - L10n.of(context).privacy, - style: TextStyle( - decoration: TextDecoration.underline, - color: Colors.blueGrey, - ), - ), - ), - TextButton( - onPressed: () => PlatformInfos.showDialog(context), - child: Text( - L10n.of(context).about, - style: TextStyle( - decoration: TextDecoration.underline, - color: Colors.blueGrey, - ), - ), - ), - ], - ), - ], - ), ), ); } diff --git a/lib/pages/views/sign_up_view.dart b/lib/pages/views/sign_up_view.dart deleted file mode 100644 index 21e146aa..00000000 --- a/lib/pages/views/sign_up_view.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:vrouter/vrouter.dart'; -import 'package:fluffychat/pages/sign_up.dart'; -import 'package:fluffychat/widgets/fluffy_banner.dart'; - -import 'package:fluffychat/widgets/matrix.dart'; -import 'package:fluffychat/widgets/layouts/one_page_card.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import '../../utils/localized_exception_extension.dart'; - -import 'package:famedlysdk/famedlysdk.dart'; - -class SignUpView extends StatelessWidget { - final SignUpController controller; - - const SignUpView(this.controller, {Key key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return OnePageCard( - child: Scaffold( - appBar: AppBar( - elevation: 0, - title: Text( - Matrix.of(context) - .client - .homeserver - .toString() - .replaceFirst('https://', ''), - ), - ), - body: FutureBuilder( - future: controller.getLoginTypes(), - builder: (context, snapshot) { - if (snapshot.hasError) { - return Center( - child: Text( - snapshot.error.toLocalizedString(context), - textAlign: TextAlign.center, - ), - ); - } - if (!snapshot.hasData) { - return Center(child: CircularProgressIndicator()); - } - return ListView(children: [ - Hero( - tag: 'loginBanner', - child: FluffyBanner(), - ), - Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (controller.ssoLoginSupported) ...{ - for (final identityProvider - in controller.identityProviders) - OutlinedButton.icon( - onPressed: () => - controller.ssoLoginAction(identityProvider.id), - icon: identityProvider.icon == null - ? Icon(Icons.web_outlined) - : CachedNetworkImage( - imageUrl: Uri.parse(identityProvider.icon) - .getDownloadLink( - Matrix.of(context).client) - .toString(), - width: 24, - height: 24, - ), - label: Text(L10n.of(context).loginWith( - identityProvider.brand ?? - identityProvider.name ?? - L10n.of(context).singlesignon)), - ), - if (controller.registrationSupported || - controller.passwordLoginSupported) - Row(children: [ - Expanded(child: Divider()), - Padding( - padding: const EdgeInsets.all(12.0), - child: Text(L10n.of(context).or), - ), - Expanded(child: Divider()), - ]), - }, - Row( - children: [ - if (controller.passwordLoginSupported) - Expanded( - child: Container( - height: 64, - child: OutlinedButton.icon( - onPressed: () => - context.vRouter.push('/login'), - icon: Icon(Icons.login_outlined), - label: Text(L10n.of(context).login), - ), - ), - ), - if (controller.registrationSupported && - controller.passwordLoginSupported) - SizedBox(width: 12), - if (controller.registrationSupported) - Expanded( - child: Container( - height: 64, - child: OutlinedButton.icon( - onPressed: controller.signUpAction, - icon: Icon(Icons.add_box_outlined), - label: Text(L10n.of(context).register), - ), - ), - ), - ], - ), - ] - .map( - (widget) => Container( - height: 64, - padding: EdgeInsets.only(bottom: 12), - child: widget), - ) - .toList(), - ), - ), - ]); - }), - ), - ); - } -} diff --git a/lib/utils/localized_exception_extension.dart b/lib/utils/localized_exception_extension.dart index 74cb04e2..4afac423 100644 --- a/lib/utils/localized_exception_extension.dart +++ b/lib/utils/localized_exception_extension.dart @@ -45,7 +45,7 @@ extension LocalizedExceptionExtension on Object { .badServerLoginTypesException(serverVersions, supportedVersions); } if (this is MatrixConnectionException || this is SocketException) { - L10n.of(context).noConnectionToTheServer; + return L10n.of(context).noConnectionToTheServer; } Logs().w('Something went wrong: ', this); return L10n.of(context).oopsSomethingWentWrong; diff --git a/test/sign_up_test.dart b/test/sign_up_test.dart deleted file mode 100644 index d0fd8e2b..00000000 --- a/test/sign_up_test.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:fluffychat/pages/sign_up.dart'; -import 'package:fluffychat/main.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - testWidgets('Test if the widget can be created', (WidgetTester tester) async { - await tester.pumpWidget(FluffyChatApp(testWidget: SignUp())); - }); -}