diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e688b5..7cd291b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,13 +3,18 @@ ### Changed - Disable commenting on locked posts - -### Fixed - -- When writing a comment the parent text is now selectable -- Text of a post is now selectable +- Enhanced keyboard experience + - appropriate keyboard types are opened + - correct capitalization + - added text input hints for things like password managers - Account actions in settings are more obvious to access: long press an account/instance to see possible actions such as setting as default or removal +### Added + +- When writing a comment, the parent text is now selectable +- Text of a post is now selectable +- Tapping outside of a text input hides the keyboard + ## v0.4.1 - 2021-04-06 ### Fixed diff --git a/lib/main.dart b/lib/main.dart index ebbe6e6..e2261fa 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:keyboard_dismisser/keyboard_dismisser.dart'; import 'package:provider/provider.dart'; import 'hooks/stores.dart'; @@ -41,15 +42,17 @@ class MyApp extends HookWidget { Widget build(BuildContext context) { final configStore = useConfigStore(); - return MaterialApp( - title: 'lemmur', - supportedLocales: L10n.supportedLocales, - localizationsDelegates: L10n.localizationsDelegates, - themeMode: configStore.theme, - darkTheme: configStore.amoledDarkMode ? amoledTheme : darkTheme, - locale: configStore.locale, - theme: lightTheme, - home: const MyHomePage(), + return KeyboardDismisser( + child: MaterialApp( + title: 'lemmur', + supportedLocales: L10n.supportedLocales, + localizationsDelegates: L10n.localizationsDelegates, + themeMode: configStore.theme, + darkTheme: configStore.amoledDarkMode ? amoledTheme : darkTheme, + locale: configStore.locale, + theme: lightTheme, + home: const MyHomePage(), + ), ); } } diff --git a/lib/pages/add_account.dart b/lib/pages/add_account.dart index 0f35693..e0af64c 100644 --- a/lib/pages/add_account.dart +++ b/lib/pages/add_account.dart @@ -24,6 +24,7 @@ class AddAccountPage extends HookWidget { final usernameController = useListenable(useTextEditingController()); final passwordController = useListenable(useTextEditingController()); + final passwordFocusNode = useFocusNode(); final accountsStore = useAccountsStore(); final loading = useDelayedLoading(); @@ -54,96 +55,109 @@ class AddAccountPage extends HookWidget { loading.cancel(); } + final handleSubmit = + usernameController.text.isEmpty || passwordController.text.isEmpty + ? null + : loading.pending + ? () {} + : handleOnAdd; + return Scaffold( appBar: AppBar( leading: const CloseButton(), title: const Text('Add account'), ), - body: ListView( - padding: const EdgeInsets.all(15), - children: [ - if (icon.value == null) - const SizedBox(height: 150) - else - SizedBox( - height: 150, - child: FullscreenableImage( - url: icon.value!, - child: CachedNetworkImage( - imageUrl: icon.value!, - errorWidget: (_, __, ___) => const SizedBox.shrink(), + body: AutofillGroup( + child: ListView( + padding: const EdgeInsets.all(15), + children: [ + if (icon.value == null) + const SizedBox(height: 150) + else + SizedBox( + height: 150, + child: FullscreenableImage( + url: icon.value!, + child: CachedNetworkImage( + imageUrl: icon.value!, + errorWidget: (_, __, ___) => const SizedBox.shrink(), + ), ), ), - ), - RadioPicker( - title: 'select instance', - values: accountsStore.instances.toList(), - groupValue: selectedInstance.value, - onChanged: (value) => selectedInstance.value = value, - buttonBuilder: (context, displayValue, onPressed) => TextButton( - onPressed: onPressed, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(displayValue), - const Icon(Icons.arrow_drop_down), - ], + RadioPicker( + title: 'select instance', + values: accountsStore.instances.toList(), + groupValue: selectedInstance.value, + onChanged: (value) => selectedInstance.value = value, + buttonBuilder: (context, displayValue, onPressed) => TextButton( + onPressed: onPressed, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(displayValue), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + trailing: ListTile( + leading: const Padding( + padding: EdgeInsets.all(8), + child: Icon(Icons.add), + ), + title: const Text('Add instance'), + onTap: () async { + final value = await showCupertinoModalPopup( + context: context, + builder: (context) => const AddInstancePage(), + ); + Navigator.of(context).pop(value); + }, ), ), - trailing: ListTile( - leading: const Padding( - padding: EdgeInsets.all(8), - child: Icon(Icons.add), - ), - title: const Text('Add instance'), - onTap: () async { - final value = await showCupertinoModalPopup( - context: context, - builder: (context) => const AddInstancePage(), - ); - Navigator.of(context).pop(value); - }, + TextField( + autofocus: true, + controller: usernameController, + autofillHints: const [ + AutofillHints.email, + AutofillHints.username + ], + onSubmitted: (_) => passwordFocusNode.requestFocus(), + decoration: InputDecoration( + labelText: L10n.of(context)!.email_or_username), ), - ), - // TODO: add support for password managers - TextField( - autofocus: true, - controller: usernameController, - decoration: - InputDecoration(labelText: L10n.of(context)!.email_or_username), - ), - const SizedBox(height: 5), - TextField( - controller: passwordController, - obscureText: true, - decoration: InputDecoration(labelText: L10n.of(context)!.password), - ), - ElevatedButton( - onPressed: usernameController.text.isEmpty || - passwordController.text.isEmpty - ? null - : loading.pending - ? () {} - : handleOnAdd, - child: !loading.loading - ? const Text('Sign in') - : SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - valueColor: - AlwaysStoppedAnimation(theme.canvasColor), + const SizedBox(height: 5), + TextField( + controller: passwordController, + obscureText: true, + focusNode: passwordFocusNode, + onSubmitted: (_) => handleSubmit?.call(), + autofillHints: const [AutofillHints.password], + keyboardType: TextInputType.visiblePassword, + decoration: + InputDecoration(labelText: L10n.of(context)!.password), + ), + ElevatedButton( + onPressed: handleSubmit, + child: !loading.loading + ? const Text('Sign in') + : SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + valueColor: + AlwaysStoppedAnimation(theme.canvasColor), + ), ), - ), - ), - TextButton( - onPressed: () { - // TODO: extract to LemmyUrls or something - ul.launch('https://${selectedInstance.value}/login'); - }, - child: const Text('Register'), - ), - ], + ), + TextButton( + onPressed: () { + // TODO: extract to LemmyUrls or something + ul.launch('https://${selectedInstance.value}/login'); + }, + child: const Text('Register'), + ), + ], + ), ), ); } diff --git a/lib/pages/add_instance.dart b/lib/pages/add_instance.dart index 89a8c74..16e71b1 100644 --- a/lib/pages/add_instance.dart +++ b/lib/pages/add_instance.dart @@ -47,7 +47,9 @@ class AddInstancePage extends HookWidget { instanceController.removeListener(debounce); }; }, []); + final inst = normalizeInstanceHost(instanceController.text); + handleOnAdd() async { try { await accountsStore.addInstance(inst, assumeValid: true); @@ -59,6 +61,8 @@ class AddInstancePage extends HookWidget { } } + final handleAdd = isSite.value == true ? handleOnAdd : null; + return Scaffold( appBar: AppBar( leading: const CloseButton(), @@ -97,6 +101,9 @@ class AddInstancePage extends HookWidget { child: TextField( autofocus: true, controller: instanceController, + autofillHints: const [AutofillHints.url], + keyboardType: TextInputType.url, + onSubmitted: (_) => handleAdd?.call(), autocorrect: false, decoration: const InputDecoration(labelText: 'instance url'), ), @@ -108,7 +115,7 @@ class AddInstancePage extends HookWidget { child: SizedBox( height: 40, child: ElevatedButton( - onPressed: isSite.value == true ? handleOnAdd : null, + onPressed: handleAdd, child: !debounce.loading ? const Text('Add') : SizedBox( diff --git a/lib/pages/create_post.dart b/lib/pages/create_post.dart index 73e40d1..48a9ab8 100644 --- a/lib/pages/create_post.dart +++ b/lib/pages/create_post.dart @@ -66,6 +66,9 @@ class CreatePostPage extends HookWidget { final pictrsDeleteToken = useState(null); final loggedInAction = useLoggedInAction(selectedInstance.value); + final titleFocusNode = useFocusNode(); + final bodyFocusNode = useFocusNode(); + final allCommunitiesSnap = useMemoFuture( () => LemmyApiV3(selectedInstance.value) .run(ListCommunities( @@ -155,77 +158,6 @@ class CreatePostPage extends HookWidget { } } - // TODO: use lazy autocomplete - final communitiesDropdown = InputDecorator( - decoration: const InputDecoration( - contentPadding: EdgeInsets.symmetric(vertical: 1, horizontal: 20), - border: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(10)))), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: selectedCommunity.value?.community.id, - hint: Text(L10n.of(context)!.community), - onChanged: (communityId) => selectedCommunity.value = - allCommunitiesSnap.data - ?.firstWhere((e) => e.community.id == communityId), - items: communitiesList(), - ), - ), - ); - - final url = Row(children: [ - Expanded( - child: TextField( - enabled: pictrsDeleteToken.value == null, - controller: urlController, - decoration: InputDecoration( - labelText: L10n.of(context)!.url, - suffixIcon: const Icon(Icons.link), - ), - ), - ), - const SizedBox(width: 5), - IconButton( - icon: imageUploadLoading.value - ? const CircularProgressIndicator() - : Icon(pictrsDeleteToken.value == null - ? Icons.add_photo_alternate - : Icons.close), - onPressed: pictrsDeleteToken.value == null - ? loggedInAction(uploadPicture) - : () => removePicture(pictrsDeleteToken.value!), - tooltip: - pictrsDeleteToken.value == null ? 'Add picture' : 'Delete picture', - ) - ]); - - final title = TextField( - controller: titleController, - minLines: 1, - maxLines: 2, - decoration: InputDecoration(labelText: L10n.of(context)!.title), - ); - - final body = IndexedStack( - index: showFancy.value ? 1 : 0, - children: [ - TextField( - controller: bodyController, - keyboardType: TextInputType.multiline, - maxLines: null, - minLines: 5, - decoration: InputDecoration(labelText: L10n.of(context)!.body), - ), - Padding( - padding: const EdgeInsets.all(16), - child: MarkdownText( - bodyController.text, - instanceHost: selectedInstance.value, - ), - ), - ], - ); - handleSubmit(Jwt token) async { if (selectedCommunity.value == null || titleController.text.isEmpty) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar( @@ -256,6 +188,88 @@ class CreatePostPage extends HookWidget { delayed.cancel(); } + // TODO: use lazy autocomplete + final communitiesDropdown = InputDecorator( + decoration: const InputDecoration( + contentPadding: EdgeInsets.symmetric(vertical: 1, horizontal: 20), + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(10)))), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: selectedCommunity.value?.community.id, + hint: Text(L10n.of(context)!.community), + onChanged: (communityId) => selectedCommunity.value = + allCommunitiesSnap.data + ?.firstWhere((e) => e.community.id == communityId), + items: communitiesList(), + ), + ), + ); + + final url = Row(children: [ + Expanded( + child: TextField( + enabled: pictrsDeleteToken.value == null, + controller: urlController, + autofillHints: const [AutofillHints.url], + keyboardType: TextInputType.url, + onSubmitted: (_) => titleFocusNode.requestFocus(), + decoration: InputDecoration( + labelText: L10n.of(context)!.url, + suffixIcon: const Icon(Icons.link), + ), + ), + ), + const SizedBox(width: 5), + IconButton( + icon: imageUploadLoading.value + ? const CircularProgressIndicator() + : Icon(pictrsDeleteToken.value == null + ? Icons.add_photo_alternate + : Icons.close), + onPressed: pictrsDeleteToken.value == null + ? loggedInAction(uploadPicture) + : () => removePicture(pictrsDeleteToken.value!), + tooltip: + pictrsDeleteToken.value == null ? 'Add picture' : 'Delete picture', + ) + ]); + + final title = TextField( + controller: titleController, + focusNode: titleFocusNode, + keyboardType: TextInputType.text, + textCapitalization: TextCapitalization.sentences, + onSubmitted: (_) => bodyFocusNode.requestFocus(), + minLines: 1, + maxLines: 2, + decoration: InputDecoration(labelText: L10n.of(context)!.title), + ); + + final body = IndexedStack( + index: showFancy.value ? 1 : 0, + children: [ + TextField( + controller: bodyController, + focusNode: bodyFocusNode, + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + onSubmitted: (_) => + delayed.pending ? () {} : loggedInAction(handleSubmit), + maxLines: null, + minLines: 5, + decoration: InputDecoration(labelText: L10n.of(context)!.body), + ), + Padding( + padding: const EdgeInsets.all(16), + child: MarkdownText( + bodyController.text, + instanceHost: selectedInstance.value, + ), + ), + ], + ); + return Scaffold( appBar: AppBar( leading: const CloseButton(), diff --git a/lib/pages/manage_account.dart b/lib/pages/manage_account.dart index eda538a..d6d35f8 100644 --- a/lib/pages/manage_account.dart +++ b/lib/pages/manage_account.dart @@ -91,6 +91,13 @@ class _ManageAccount extends HookWidget { final deleteAccountPasswordController = useTextEditingController(); + final bioFocusNode = useFocusNode(); + final emailFocusNode = useFocusNode(); + final matrixUserFocusNode = useFocusNode(); + final newPasswordFocusNode = useFocusNode(); + final verifyPasswordFocusNode = useFocusNode(); + final oldPasswordFocusNode = useFocusNode(); + final token = accountsStore.tokenFor(user.instanceHost, user.person.name)!; handleSubmit() async { @@ -155,6 +162,8 @@ class _ManageAccount extends HookWidget { const SizedBox(height: 10), TextField( controller: deleteAccountPasswordController, + autofillHints: const [AutofillHints.password], + keyboardType: TextInputType.visiblePassword, obscureText: true, decoration: InputDecoration(hintText: L10n.of(context)!.password), @@ -219,37 +228,64 @@ class _ManageAccount extends HookWidget { ), const SizedBox(height: 8), Text(L10n.of(context)!.display_name, style: theme.textTheme.headline6), - TextField(controller: displayNameController), + TextField( + controller: displayNameController, + onSubmitted: (_) => bioFocusNode.requestFocus(), + ), const SizedBox(height: 8), Text(L10n.of(context)!.bio, style: theme.textTheme.headline6), TextField( controller: bioController, + focusNode: bioFocusNode, + textCapitalization: TextCapitalization.sentences, + onSubmitted: (_) => emailFocusNode.requestFocus(), minLines: 4, maxLines: 10, ), const SizedBox(height: 8), Text(L10n.of(context)!.email, style: theme.textTheme.headline6), - TextField(controller: emailController), + TextField( + focusNode: emailFocusNode, + controller: emailController, + autofillHints: const [AutofillHints.email], + keyboardType: TextInputType.emailAddress, + onSubmitted: (_) => matrixUserFocusNode.requestFocus(), + ), const SizedBox(height: 8), Text(L10n.of(context)!.matrix_user, style: theme.textTheme.headline6), - TextField(controller: matrixUserController), + TextField( + focusNode: matrixUserFocusNode, + controller: matrixUserController, + onSubmitted: (_) => newPasswordFocusNode.requestFocus(), + ), const SizedBox(height: 8), Text(L10n.of(context)!.new_password, style: theme.textTheme.headline6), TextField( + focusNode: newPasswordFocusNode, controller: newPasswordController, + autofillHints: const [AutofillHints.newPassword], + keyboardType: TextInputType.visiblePassword, obscureText: true, + onSubmitted: (_) => verifyPasswordFocusNode.requestFocus(), ), const SizedBox(height: 8), Text(L10n.of(context)!.verify_password, style: theme.textTheme.headline6), TextField( + focusNode: verifyPasswordFocusNode, controller: newPasswordVerifyController, + autofillHints: const [AutofillHints.newPassword], + keyboardType: TextInputType.visiblePassword, obscureText: true, + onSubmitted: (_) => oldPasswordFocusNode.requestFocus(), ), const SizedBox(height: 8), Text(L10n.of(context)!.old_password, style: theme.textTheme.headline6), TextField( + focusNode: oldPasswordFocusNode, controller: oldPasswordController, + autofillHints: const [AutofillHints.password], + keyboardType: TextInputType.visiblePassword, obscureText: true, ), const SizedBox(height: 8), diff --git a/lib/pages/search_tab.dart b/lib/pages/search_tab.dart index f749e2c..a0b02be 100644 --- a/lib/pages/search_tab.dart +++ b/lib/pages/search_tab.dart @@ -31,47 +31,51 @@ class SearchTab extends HookWidget { ); } + handleSearch() => searchInputController.text.isNotEmpty + ? goTo( + context, + (context) => SearchResultsPage( + instanceHost: instanceHost.value!, + query: searchInputController.text, + ), + ) + : null; + return Scaffold( appBar: AppBar(), - body: GestureDetector( - onTapDown: (_) => primaryFocus?.unfocus(), - child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 20), - children: [ - TextField( - controller: searchInputController, - textAlign: TextAlign.center, - decoration: InputDecoration(hintText: L10n.of(context)!.search), - ), - const SizedBox(height: 5), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: Text('instance:', - style: Theme.of(context).textTheme.subtitle1), + body: ListView( + padding: const EdgeInsets.symmetric(horizontal: 20), + children: [ + TextField( + controller: searchInputController, + keyboardType: TextInputType.text, + textAlign: TextAlign.center, + onSubmitted: (_) => handleSearch(), + decoration: InputDecoration(hintText: L10n.of(context)!.search), + ), + const SizedBox(height: 5), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Text('instance:', + style: Theme.of(context).textTheme.subtitle1), + ), + Expanded( + child: RadioPicker( + values: accStore.instances.toList(), + groupValue: instanceHost.value!, + onChanged: (value) => instanceHost.value = value, ), - Expanded( - child: RadioPicker( - values: accStore.instances.toList(), - groupValue: instanceHost.value!, - onChanged: (value) => instanceHost.value = value, - ), - ), - ], - ), - if (searchInputController.text.isNotEmpty) - ElevatedButton( - onPressed: () => goTo( - context, - (c) => SearchResultsPage( - instanceHost: instanceHost.value!, - query: searchInputController.text, - )), - child: Text(L10n.of(context)!.search), - ) - ], - ), + ), + ], + ), + if (searchInputController.text.isNotEmpty) + ElevatedButton( + onPressed: handleSearch, + child: Text(L10n.of(context)!.search), + ) + ], ), ); } diff --git a/lib/pages/write_message.dart b/lib/pages/write_message.dart index 106a419..1f1be79 100644 --- a/lib/pages/write_message.dart +++ b/lib/pages/write_message.dart @@ -86,6 +86,7 @@ class WriteMessagePage extends HookWidget { TextField( controller: bodyController, keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, maxLines: null, minLines: 5, autofocus: true, diff --git a/lib/widgets/write_comment.dart b/lib/widgets/write_comment.dart index 51d2e86..1669b92 100644 --- a/lib/widgets/write_comment.dart +++ b/lib/widgets/write_comment.dart @@ -97,6 +97,8 @@ class WriteComment extends HookWidget { children: [ TextField( controller: controller, + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, autofocus: true, minLines: 5, maxLines: null, diff --git a/pubspec.lock b/pubspec.lock index 207920b..00e9e7d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -392,6 +392,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.1.0" + keyboard_dismisser: + dependency: "direct main" + description: + name: keyboard_dismisser + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" latinize: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 361f91a..20d5e59 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,7 @@ dependencies: intl: ^0.17.0 matrix4_transform: ^2.0.0 json_annotation: ^4.0.1 + keyboard_dismisser: ^2.0.0 flutter: sdk: flutter