diff --git a/lib/api/route/realm.dart b/lib/api/route/realm.dart index 1f366f5cc5..548769847a 100644 --- a/lib/api/route/realm.dart +++ b/lib/api/route/realm.dart @@ -16,15 +16,13 @@ part 'realm.g.dart'; // See thread, and the zulip-mobile code and chat thread it links to: // https://github.com/zulip/zulip-flutter/pull/55#discussion_r1160267577 Future getServerSettings({ - required Uri realmUrl, + required ApiConnection apiConnection, }) async { final Map data; - // TODO make this function testable by taking ApiConnection from caller - final connection = ApiConnection.live(realmUrl: realmUrl); try { - data = await connection.get('server_settings', null); + data = await apiConnection.get('server_settings', null); } finally { - connection.close(); + apiConnection.close(); } return GetServerSettingsResult.fromJson(data); @@ -67,7 +65,7 @@ class GetServerSettingsResult { }); factory GetServerSettingsResult.fromJson(Map json) => - _$GetServerSettingsResultFromJson(json); + _$GetServerSettingsResultFromJson(json); Map toJson() => _$GetServerSettingsResultToJson(this); } diff --git a/lib/widgets/login.dart b/lib/widgets/login.dart index 55ada906ad..f287965de6 100644 --- a/lib/widgets/login.dart +++ b/lib/widgets/login.dart @@ -40,7 +40,8 @@ enum ServerUrlValidationError { } } - String message() { // TODO(i18n) + String message() { + // TODO(i18n) switch (this) { case empty: return 'Please enter a URL.'; @@ -79,9 +80,14 @@ class ServerUrlTextEditingController extends TextEditingController { // TODO(log): Log to Sentry? How much does this happen, if at all? Maybe // log once when the input enters this error state, but don't spam // on every keystroke/render while it's in it. - return ServerUrlParseResult.error(ServerUrlValidationError.unsupportedSchemeZulip); - } else if (url != null && url.hasScheme && url.scheme != 'http' && url.scheme != 'https') { - return ServerUrlParseResult.error(ServerUrlValidationError.unsupportedSchemeOther); + return ServerUrlParseResult.error( + ServerUrlValidationError.unsupportedSchemeZulip); + } else if (url != null && + url.hasScheme && + url.scheme != 'http' && + url.scheme != 'https') { + return ServerUrlParseResult.error( + ServerUrlValidationError.unsupportedSchemeOther); } url = Uri.tryParse('https://$trimmedText'); } @@ -100,8 +106,7 @@ class AddAccountPage extends StatefulWidget { const AddAccountPage({super.key}); static Route buildRoute() { - return _LoginSequenceRoute(builder: (context) => - const AddAccountPage()); + return _LoginSequenceRoute(builder: (context) => const AddAccountPage()); } @override @@ -111,7 +116,8 @@ class AddAccountPage extends StatefulWidget { class _AddAccountPageState extends State { bool _inProgress = false; - final ServerUrlTextEditingController _controller = ServerUrlTextEditingController(); + final ServerUrlTextEditingController _controller = + ServerUrlTextEditingController(); late ServerUrlParseResult _parseResult; _serverUrlChanged() { @@ -137,8 +143,8 @@ class _AddAccountPageState extends State { final url = _parseResult.url; final error = _parseResult.error; if (error != null) { - showErrorDialog(context: context, - title: 'Invalid input', message: error.message()); + showErrorDialog( + context: context, title: 'Invalid input', message: error.message()); return; } assert(url != null); @@ -149,15 +155,18 @@ class _AddAccountPageState extends State { try { final GetServerSettingsResult serverSettings; try { - serverSettings = await getServerSettings(realmUrl: url!); + serverSettings = await getServerSettings( + apiConnection: ApiConnection.live(realmUrl: url!)); } catch (e) { if (!context.mounted) { return; } // TODO(#35) give more helpful feedback; see `fetchServerSettings` // in zulip-mobile's src/message/fetchActions.js. Needs #37. - showErrorDialog(context: context, - title: 'Could not connect', message: 'Failed to connect to server:\n$url'); + showErrorDialog( + context: context, + title: 'Could not connect', + message: 'Failed to connect to server:\n$url'); return; } // https://github.com/dart-lang/linter/issues/4007 @@ -168,7 +177,7 @@ class _AddAccountPageState extends State { // TODO(#36): support login methods beyond username/password Navigator.push(context, - PasswordLoginPage.buildRoute(serverSettings: serverSettings)); + PasswordLoginPage.buildRoute(serverSettings: serverSettings)); } finally { setState(() { _inProgress = false; @@ -180,46 +189,50 @@ class _AddAccountPageState extends State { Widget build(BuildContext context) { assert(!PerAccountStoreWidget.debugExistsOf(context)); final error = _parseResult.error; - final errorText = error == null || error.shouldDeferFeedback() - ? null - : error.message(); + final errorText = + error == null || error.shouldDeferFeedback() ? null : error.message(); // TODO(#35): more help to user on entering realm URL return Scaffold( - appBar: AppBar(title: const Text('Add an account'), - bottom: _inProgress - ? const PreferredSize(preferredSize: Size.fromHeight(4), - child: LinearProgressIndicator(minHeight: 4)) // 4 restates default - : null), - body: SafeArea( - minimum: const EdgeInsets.all(8), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - TextField( - controller: _controller, - onSubmitted: (value) => _onSubmitted(context), - keyboardType: TextInputType.url, - autocorrect: false, - textInputAction: TextInputAction.go, - onEditingComplete: () { - // Repeat default implementation by clearing IME compose session… - _controller.clearComposing(); - // …but leave out unfocusing the input in case more editing is needed. - }, - decoration: InputDecoration( - labelText: 'Your Zulip server URL', - errorText: errorText, - helperText: kLayoutPinningHelperText, - hintText: 'your-org.zulipchat.com')), - const SizedBox(height: 8), - ElevatedButton( - onPressed: !_inProgress && errorText == null - ? () => _onSubmitted(context) - : null, - child: const Text('Continue')), - ]))))); + appBar: AppBar( + title: const Text('Add an account'), + bottom: _inProgress + ? const PreferredSize( + preferredSize: Size.fromHeight(4), + child: LinearProgressIndicator( + minHeight: 4)) // 4 restates default + : null), + body: SafeArea( + minimum: const EdgeInsets.all(8), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _controller, + onSubmitted: (value) => _onSubmitted(context), + keyboardType: TextInputType.url, + autocorrect: false, + textInputAction: TextInputAction.go, + onEditingComplete: () { + // Repeat default implementation by clearing IME compose session… + _controller.clearComposing(); + // …but leave out unfocusing the input in case more editing is needed. + }, + decoration: InputDecoration( + labelText: 'Your Zulip server URL', + errorText: errorText, + helperText: kLayoutPinningHelperText, + hintText: 'your-org.zulipchat.com')), + const SizedBox(height: 8), + ElevatedButton( + onPressed: !_inProgress && errorText == null + ? () => _onSubmitted(context) + : null, + child: const Text('Continue')), + ]))))); } } @@ -228,9 +241,11 @@ class PasswordLoginPage extends StatefulWidget { final GetServerSettingsResult serverSettings; - static Route buildRoute({required GetServerSettingsResult serverSettings}) { - return _LoginSequenceRoute(builder: (context) => - PasswordLoginPage(serverSettings: serverSettings)); + static Route buildRoute( + {required GetServerSettingsResult serverSettings}) { + return _LoginSequenceRoute( + builder: (context) => + PasswordLoginPage(serverSettings: serverSettings)); } @override @@ -252,8 +267,11 @@ class _PasswordLoginPageState extends State { Future _getUserId(FetchApiKeyResult fetchApiKeyResult) async { final FetchApiKeyResult(:email, :apiKey) = fetchApiKeyResult; - final connection = ApiConnection.live( // TODO make this widget testable - realmUrl: widget.serverSettings.realmUri, email: email, apiKey: apiKey); + final connection = ApiConnection.live( + // TODO make this widget testable + realmUrl: widget.serverSettings.realmUri, + email: email, + apiKey: apiKey); return (await getOwnUser(connection)).userId; } @@ -262,8 +280,10 @@ class _PasswordLoginPageState extends State { final realmUrl = widget.serverSettings.realmUri; final usernameFieldState = _usernameKey.currentState!; final passwordFieldState = _passwordKey.currentState!; - final usernameValid = usernameFieldState.validate(); // Side effect: on-field error text - final passwordValid = passwordFieldState.validate(); // Side effect: on-field error text + final usernameValid = + usernameFieldState.validate(); // Side effect: on-field error text + final passwordValid = + passwordFieldState.validate(); // Side effect: on-field error text if (!usernameValid || !passwordValid) { return; } @@ -277,8 +297,9 @@ class _PasswordLoginPageState extends State { final FetchApiKeyResult result; try { result = await fetchApiKey( - realmUrl: realmUrl, username: username, password: password); - } on Exception { // TODO(#37): distinguish API exceptions + realmUrl: realmUrl, username: username, password: password); + } on Exception { + // TODO(#37): distinguish API exceptions if (!context.mounted) return; // TODO(#35) give more helpful feedback. Needs #37. The RN app is // unhelpful here; we should at least recognize invalid auth errors, and @@ -297,7 +318,8 @@ class _PasswordLoginPageState extends State { final globalStore = GlobalStoreWidget.of(context); // TODO(#35): give feedback to user on SQL exception, like dupe realm+user - final accountId = await globalStore.insertAccount(AccountsCompanion.insert( + final accountId = + await globalStore.insertAccount(AccountsCompanion.insert( realmUrl: realmUrl, email: result.email, apiKey: result.apiKey, @@ -326,80 +348,88 @@ class _PasswordLoginPageState extends State { @override Widget build(BuildContext context) { assert(!PerAccountStoreWidget.debugExistsOf(context)); - final requireEmailFormatUsernames = widget.serverSettings.requireEmailFormatUsernames; + final requireEmailFormatUsernames = + widget.serverSettings.requireEmailFormatUsernames; final usernameField = TextFormField( - key: _usernameKey, - autofillHints: [ - if (!requireEmailFormatUsernames) AutofillHints.username, - AutofillHints.email, - ], - keyboardType: TextInputType.emailAddress, - // TODO(upstream?): Apparently pressing "next" doesn't count - // as user interaction, and validation isn't done. - autovalidateMode: AutovalidateMode.onUserInteraction, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return requireEmailFormatUsernames - ? 'Please enter your email.' - : 'Please enter your username.'; - } - if (requireEmailFormatUsernames) { - // TODO(#35): validate is in the shape of an email - } - return null; - }, - textInputAction: TextInputAction.next, - decoration: InputDecoration( - labelText: requireEmailFormatUsernames ? 'Email address' : 'Username', - helperText: kLayoutPinningHelperText, - )); + key: _usernameKey, + autofillHints: [ + if (!requireEmailFormatUsernames) AutofillHints.username, + AutofillHints.email, + ], + keyboardType: TextInputType.emailAddress, + // TODO(upstream?): Apparently pressing "next" doesn't count + // as user interaction, and validation isn't done. + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return requireEmailFormatUsernames + ? 'Please enter your email.' + : 'Please enter your username.'; + } + if (requireEmailFormatUsernames) { + // TODO(#35): validate is in the shape of an email + } + return null; + }, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: requireEmailFormatUsernames ? 'Email address' : 'Username', + helperText: kLayoutPinningHelperText, + )); final passwordField = TextFormField( - key: _passwordKey, - autofillHints: const [AutofillHints.password], - obscureText: _obscurePassword, - keyboardType: TextInputType.visiblePassword, - autovalidateMode: AutovalidateMode.onUserInteraction, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter your password.'; - } - return null; - }, - textInputAction: TextInputAction.go, - onFieldSubmitted: (value) => _submit(), - decoration: InputDecoration( - labelText: 'Password', - helperText: kLayoutPinningHelperText, - suffixIcon: Semantics(label: 'Hide password', toggled: _obscurePassword, - child: IconButton( - onPressed: _handlePasswordVisibilityPress, - icon: _obscurePassword - ? const Icon(Icons.visibility_off) - : const Icon(Icons.visibility))))); + key: _passwordKey, + autofillHints: const [AutofillHints.password], + obscureText: _obscurePassword, + keyboardType: TextInputType.visiblePassword, + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your password.'; + } + return null; + }, + textInputAction: TextInputAction.go, + onFieldSubmitted: (value) => _submit(), + decoration: InputDecoration( + labelText: 'Password', + helperText: kLayoutPinningHelperText, + suffixIcon: Semantics( + label: 'Hide password', + toggled: _obscurePassword, + child: IconButton( + onPressed: _handlePasswordVisibilityPress, + icon: _obscurePassword + ? const Icon(Icons.visibility_off) + : const Icon(Icons.visibility))))); return Scaffold( - appBar: AppBar(title: const Text('Log in'), - bottom: _inProgress - ? const PreferredSize(preferredSize: Size.fromHeight(4), - child: LinearProgressIndicator(minHeight: 4)) // 4 restates default - : null), - body: SafeArea( - minimum: const EdgeInsets.all(8), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Form( - child: AutofillGroup( - child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - usernameField, - const SizedBox(height: 8), - passwordField, - const SizedBox(height: 8), - ElevatedButton( - onPressed: _inProgress ? null : _submit, - child: const Text('Log in')), - ]))))))); + appBar: AppBar( + title: const Text('Log in'), + bottom: _inProgress + ? const PreferredSize( + preferredSize: Size.fromHeight(4), + child: LinearProgressIndicator( + minHeight: 4)) // 4 restates default + : null), + body: SafeArea( + minimum: const EdgeInsets.all(8), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Form( + child: AutofillGroup( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + usernameField, + const SizedBox(height: 8), + passwordField, + const SizedBox(height: 8), + ElevatedButton( + onPressed: _inProgress ? null : _submit, + child: const Text('Log in')), + ]))))))); } }