diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart index 5558beb..fb57735 100644 --- a/lib/controllers/client_controller.dart +++ b/lib/controllers/client_controller.dart @@ -261,12 +261,12 @@ class ClientController extends AsyncNotifier { } } - Future discoverHomeserver(Uri homeserver) async { + Future discoverHomeserver(Uri homeserver) async { try { final response = await _sendCommand("discover_homeserver", { "user_id": "@fake-user:${homeserver.host}", }); - return response["m.homeserver"]?["base_url"]; + return Uri.parse(response["m.homeserver"]?["base_url"]); } catch (error) { return null; } diff --git a/lib/main.dart b/lib/main.dart index dcf7b67..dab4e16 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,7 +11,7 @@ import "package:nexus/controllers/shared_prefs_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/scheme_to_theme.dart"; import "package:nexus/pages/chat_page.dart"; -import "package:nexus/pages/login_page.dart"; +import "package:nexus/pages/select_server_page.dart"; import "package:nexus/pages/verify_page.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/loading.dart"; @@ -124,7 +124,7 @@ class App extends StatelessWidget { } if (!clientState.isLoggedIn) { - return LoginPage(); + return SelectServerPage(); } else if (!clientState.isVerified) { return VerifyPage(); } else { diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart index 58662f4..38941e2 100644 --- a/lib/pages/login_page.dart +++ b/lib/pages/login_page.dart @@ -1,206 +1,135 @@ import "package:flutter/material.dart"; import "package:flutter_hooks/flutter_hooks.dart"; -import "package:flutter_svg/flutter_svg.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/helpers/launch_helper.dart"; -import "package:nexus/models/homeserver.dart"; +import "package:nexus/models/requests/login_request.dart"; import "package:nexus/widgets/appbar.dart"; -import "package:nexus/widgets/divider_text.dart"; -import "package:nexus/widgets/loading.dart"; class LoginPage extends HookConsumerWidget { - const LoginPage({super.key}); + final Uri homeserver; + + const LoginPage({super.key, required this.homeserver}); @override Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); final client = ref.watch(ClientController.provider.notifier); + final isLoggingIn = useState(false); + final hasError = useState(false); + final passwordFocusNode = useFocusNode(); - final isLoading = useState(false); - final homeserver = useState(null); + final theme = Theme.of(context); - final launch = ref.watch(LaunchHelper.provider).launchUrl; + final username = useTextEditingController(); + final password = useTextEditingController(); - Future setHomeserver(Uri? newHomeserver) async { - isLoading.value = true; + Future tryLogin() async { + if (isLoggingIn.value) return; + isLoggingIn.value = true; + hasError.value = false; - homeserver.value = newHomeserver == null - ? null - : await client.discoverHomeserver( - newHomeserver.hasScheme - ? newHomeserver - : Uri.https(newHomeserver.path), - ); + final error = await client.login( + LoginRequest( + username: username.text, + password: password.text, + homeserverUrl: homeserver.origin, + ), + ); - if (homeserver.value == null && context.mounted) { + if (!context.mounted) return; + + if (error != null) { + hasError.value = true; ScaffoldMessenger.of(context).showSnackBar( - .new( + SnackBar( content: Text( - "Homeserver verification failed. Is your homeserver down?", + "Login failed. Is your password right?\nError: $error", style: TextStyle(color: theme.colorScheme.onErrorContainer), ), backgroundColor: theme.colorScheme.errorContainer, ), ); + isLoggingIn.value = false; + } else { + Navigator.of(context).pop(); } - isLoading.value = false; + passwordFocusNode.requestFocus(); + isLoggingIn.value = false; } - final homeserverUrl = useTextEditingController(); - final username = useTextEditingController(); - final password = useTextEditingController(); - return Scaffold( - appBar: Appbar(), - body: Center( - child: ConstrainedBox( - constraints: .new(maxWidth: 600), - child: ListView( - padding: .symmetric(horizontal: 16, vertical: 64), - children: [ - Row( - children: [ - SvgPicture.asset("assets/icon.svg", width: 128), - SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: .start, - children: [ - Text("Nexus", style: theme.textTheme.displayMedium), - Text( - "A Simple Matrix Client", - style: theme.textTheme.headlineMedium, - overflow: .ellipsis, - ), - ], - ), - ), - ], - ), - Padding(padding: .symmetric(vertical: 12), child: Divider()), - - DividerText("Enter a homeserver domain:"), - Row( - spacing: 8, - children: [ - Expanded( - child: TextField( - controller: homeserverUrl, - decoration: .new( - labelText: "Homeserver URL (e.g. matrix.org)", - ), - ), - ), - IconButton.filled( - tooltip: "Confirm homeserver choice", - onPressed: isLoading.value - ? null - : () => setHomeserver(.tryParse(homeserverUrl.text)), - icon: Icon(Icons.check), - ), - ], - ), - - DividerText("Or, choose from some popular homeservers:"), - ...([ - .new( - name: "Matrix.org", - description: - "The Matrix.org Foundation offers the matrix.org homeserver as an easy entry point for anyone wanting to try out Matrix.", - url: .https("matrix.org"), - iconUrl: - "https://raw.githubusercontent.com/element-hq/logos/refs/heads/master/matrix/matrix-favicon${Theme.brightnessOf(context) == Brightness.dark ? "-white" : ""}.png", - ), - .new( - name: "Federated Nexus", - description: - "Federated Nexus is a community resource hosting multiple FOSS (especially federated) services, including Matrix and Forgejo. By the same developers who made Nexus client.", - url: .https("federated.nexus"), - iconUrl: "https://federated.nexus/images/icon.png", - ), - .new( - name: "Unredacted", - description: - "Unredacted is a 501(c)(3) non-profit organization that builds Internet infrastructure and services to help people evade censorship and protect their right to privacy.", - url: .https("unredacted.org", "services/si/matrix"), - iconUrl: "https://unredacted.org/favicon.ico", - ), - ].map( - (homeserver) => Card( - child: ListTile( - title: Text(homeserver.name), - leading: Image.network( - homeserver.iconUrl, - errorBuilder: (_, _, _) => SizedBox.shrink(), - height: 32, - ), - subtitle: Text(homeserver.description), - onTap: isLoading.value - ? null - : () => setHomeserver(homeserver.url), - trailing: IconButton( - tooltip: "Launch homeserver info page", - onPressed: () => launch(homeserver.url), - icon: Icon(Icons.info_outline), - ), - ), - ), - )), - SizedBox(height: 8), - TextButton( - onPressed: () => launch(.https("servers.joinmatrix.org")), - child: Text("See more homeservers..."), - ), - if (isLoading.value) - Padding(padding: .only(top: 32), child: Loading()) - else if (homeserver.value != null) ...[ - DividerText("Then, sign in:"), - SizedBox(height: 4), - TextField( - decoration: .new(label: Text("Username")), - controller: username, - ), - SizedBox(height: 12), - TextField( - decoration: .new(label: Text("Password")), - controller: password, - obscureText: true, - ), - SizedBox(height: 12), - ElevatedButton( - onPressed: () async { - isLoading.value = true; - final error = await client.login( - .new( - username: username.text, - password: password.text, - homeserverUrl: homeserver.value!, - ), - ); - - if (error != null && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - .new( - content: Text( - "Login failed. Is your password right?\nError: $error", - style: .new( - color: theme.colorScheme.onErrorContainer, - ), - ), - backgroundColor: theme.colorScheme.errorContainer, - ), - ); - isLoading.value = false; - } - }, - child: Text("Sign In"), - ), - ], - ], - ), + appBar: Appbar( + leading: IconButton( + icon: Icon(Icons.arrow_back), + onPressed: Navigator.of(context).pop, ), ), + body: AlertDialog( + title: Text("Login to ${homeserver.host}"), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Enter your login credentials:"), + SizedBox(height: 12), + TextField( + autofocus: true, + textInputAction: TextInputAction.next, + onChanged: (newVal) { + if (hasError.value) { + hasError.value = false; + } + }, + decoration: InputDecoration( + label: Text("Username"), + focusedBorder: hasError.value + ? OutlineInputBorder( + borderSide: BorderSide(color: theme.colorScheme.error), + ) + : null, + enabledBorder: hasError.value + ? OutlineInputBorder( + borderSide: BorderSide(color: theme.colorScheme.error), + ) + : null, + ), + controller: username, + ), + SizedBox(height: 12), + TextField( + focusNode: passwordFocusNode, + textInputAction: TextInputAction.done, + onSubmitted: (_) => tryLogin(), + onChanged: (newVal) { + if (hasError.value) { + hasError.value = false; + } + }, + selectAllOnFocus: true, + decoration: InputDecoration( + label: Text("Password"), + focusedBorder: hasError.value + ? OutlineInputBorder( + borderSide: BorderSide(color: theme.colorScheme.error), + ) + : null, + enabledBorder: hasError.value + ? OutlineInputBorder( + borderSide: BorderSide(color: theme.colorScheme.error), + ) + : null, + ), + controller: password, + obscureText: true, + ), + ], + ), + actions: [ + TextButton( + onPressed: hasError.value ? null : tryLogin, + child: Text("Sign In"), + ), + ], + ), ); } } diff --git a/lib/pages/select_server_page.dart b/lib/pages/select_server_page.dart new file mode 100644 index 0000000..cd9ef83 --- /dev/null +++ b/lib/pages/select_server_page.dart @@ -0,0 +1,214 @@ +import "package:flutter/material.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:flutter_svg/flutter_svg.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/helpers/launch_helper.dart"; +import "package:nexus/models/homeserver.dart"; +import "package:nexus/pages/login_page.dart"; +import "package:nexus/widgets/appbar.dart"; +import "package:nexus/widgets/divider_text.dart"; +import "package:nexus/widgets/loading.dart"; + +class SelectServerPage extends HookConsumerWidget { + const SelectServerPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final client = ref.watch(ClientController.provider.notifier); + final hasError = useState(false); + final isLoading = useState(false); + final homeserverFocusNode = useFocusNode(); + + final launch = ref.watch(LaunchHelper.provider).launchUrl; + final homeserverUrl = useTextEditingController(); + + Future setHomeserver(Uri? newHomeserver) async { + isLoading.value = true; + + if (newHomeserver?.hasScheme == false) { + newHomeserver = Uri.https(newHomeserver!.path); + } + + final newUrl = newHomeserver == null + ? null + : await client.discoverHomeserver(newHomeserver); + + if (context.mounted) { + if (newUrl == null) { + hasError.value = true; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "Homeserver verification failed. Is your homeserver down?", + style: TextStyle(color: theme.colorScheme.onErrorContainer), + ), + backgroundColor: theme.colorScheme.errorContainer, + ), + ); + } else { + homeserverUrl.text = newHomeserver!.origin; + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => LoginPage(homeserver: newUrl)), + ); + } + } + + homeserverFocusNode.requestFocus(); + isLoading.value = false; + } + + return Scaffold( + appBar: Appbar(), + body: isLoading.value + ? const Loading() + : Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 600), + child: Column( + children: [ + Row( + children: [ + SvgPicture.asset("assets/icon.svg", width: 128), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Nexus", + style: theme.textTheme.displayMedium, + ), + Text( + "A Simple Matrix Client", + style: theme.textTheme.headlineMedium, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + Padding( + padding: EdgeInsetsGeometry.symmetric(vertical: 12), + child: Divider(), + ), + DividerText("Enter a homeserver domain:"), + Row( + spacing: 8, + children: [ + Expanded( + child: TextField( + focusNode: homeserverFocusNode, + textInputAction: TextInputAction.done, + onSubmitted: (_) => + setHomeserver(Uri.tryParse(homeserverUrl.text)), + onChanged: (newVal) { + if (hasError.value) { + hasError.value = false; + } + }, + controller: homeserverUrl, + decoration: InputDecoration( + labelText: "Homeserver URL (e.g. matrix.org)", + hintText: "e.g. matrix.org", + focusedBorder: hasError.value + ? OutlineInputBorder( + borderSide: BorderSide( + color: theme.colorScheme.error, + ), + ) + : null, + enabledBorder: hasError.value + ? OutlineInputBorder( + borderSide: BorderSide( + color: theme.colorScheme.error, + ), + ) + : null, + ), + ), + ), + IconButton.filled( + tooltip: "Confirm homeserver choice", + onPressed: isLoading.value || hasError.value + ? null + : () => setHomeserver( + Uri.tryParse(homeserverUrl.text), + ), + icon: Icon(Icons.check), + ), + ], + ), + Expanded( + child: ListView( + padding: EdgeInsets.only(top: 12), + children: [ + DividerText( + "Or, choose from some popular homeservers:", + ), + ...([ + Homeserver( + name: "Matrix.org", + description: + "The Matrix.org Foundation offers the matrix.org homeserver as an easy entry point for anyone wanting to try out Matrix.", + url: Uri.https("matrix.org"), + iconUrl: + "https://raw.githubusercontent.com/element-hq/logos/refs/heads/master/matrix/matrix-favicon${Theme.brightnessOf(context) == Brightness.dark ? "-white" : ""}.png", + ), + Homeserver( + name: "Federated Nexus", + description: + "Federated Nexus is a community resource hosting multiple FOSS (especially federated) services, including Matrix and Forgejo. By the same developers who made Nexus client.", + url: Uri.https("federated.nexus"), + iconUrl: + "https://federated.nexus/images/icon.png", + ), + Homeserver( + name: "Unredacted", + description: + "Unredacted is a 501(c)(3) non-profit organization that builds Internet infrastructure and services to help people evade censorship and protect their right to privacy.", + url: Uri.https( + "unredacted.org", + "services/si/matrix", + ), + iconUrl: "https://unredacted.org/favicon.ico", + ), + ].map( + (homeserver) => Card( + child: ListTile( + title: Text(homeserver.name), + leading: Image.network( + homeserver.iconUrl, + errorBuilder: (_, _, _) => SizedBox.shrink(), + height: 32, + ), + subtitle: Text(homeserver.description), + onTap: isLoading.value + ? null + : () => setHomeserver(homeserver.url), + trailing: IconButton( + tooltip: "Launch homeserver info page", + onPressed: () => launch(homeserver.url), + icon: Icon(Icons.info_outline), + ), + ), + ), + )), + ], + ), + ), + SizedBox(height: 5), + TextButton( + onPressed: () => + launch(Uri.https("servers.joinmatrix.org")), + child: Text("See more homeservers..."), + ), + ], + ), + ), + ), + ); + } +}