feat: Improved UX design for new chat page

This commit is contained in:
krille-chan 2023-11-19 12:49:15 +01:00
parent 4588d82dad
commit f58b9b814a
No known key found for this signature in database
5 changed files with 264 additions and 122 deletions

View file

@ -2550,5 +2550,14 @@
"editTodo": "Edit todo",
"pleaseAddATitle": "Please add a title",
"todoListChangedError": "Oops... The todo list has been changed while you edited it.",
"todosUnencrypted": "Please notice that todos are visible by everyone in the chat and are not end to end encrypted."
"todosUnencrypted": "Please notice that todos are visible by everyone in the chat and are not end to end encrypted.",
"yourGlobalUserIdIs": "Your global user-ID is: ",
"noUsersFoundWithQuery": "Unfortunately no user could be found with \"{query}\". Please check whether you made a typo.",
"@noUsersFoundWithQuery": {
"type": "text",
"placeholders": {
"query": {}
}
},
"searchChatsRooms": "Search for #chats, @users..."
}

View file

@ -25,6 +25,8 @@ abstract class AppConfig {
'https://github.com/krille-chan/fluffychat/wiki/Push-Notifications-without-Google-Services';
static const String encryptionTutorial =
'https://github.com/krille-chan/fluffychat/wiki/How-to-use-end-to-end-encryption-in-FluffyChat';
static const String startChatTutorial =
'https://github.com/krille-chan/fluffychat/wiki/How-to-Find-Users-in-FluffyChat';
static const String appId = 'im.fluffychat.FluffyChat';
static const String appOpenUrlScheme = 'im.fluffychat';
static String _webBaseUrl = 'https://fluffychat.im/web';

View file

@ -54,7 +54,7 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
borderSide: BorderSide.none,
borderRadius: BorderRadius.circular(99),
),
hintText: L10n.of(context)!.search,
hintText: L10n.of(context)!.searchChatsRooms,
floatingLabelBehavior: FloatingLabelBehavior.never,
prefixIcon: controller.isSearchMode
? IconButton(

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -7,6 +9,7 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/pages/new_private_chat/new_private_chat_view.dart';
import 'package:fluffychat/pages/new_private_chat/qr_scanner_modal.dart';
import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart';
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:fluffychat/utils/platform_infos.dart';
@ -23,38 +26,30 @@ class NewPrivateChat extends StatefulWidget {
class NewPrivateChatController extends State<NewPrivateChat> {
final TextEditingController controller = TextEditingController();
final FocusNode textFieldFocus = FocusNode();
final formKey = GlobalKey<FormState>();
bool loading = false;
// remove leading matrix.to from text field in order to simplify pasting
final List<TextInputFormatter> removeMatrixToFormatters = [
FilteringTextInputFormatter.deny(NewPrivateChatController.prefix),
FilteringTextInputFormatter.deny(NewPrivateChatController.prefixNoProtocol),
];
Future<SearchUserDirectoryResponse>? searchResponse;
static const Set<String> supportedSigils = {'@', '!', '#'};
Timer? _searchCoolDown;
static const String prefix = 'https://matrix.to/#/';
static const String prefixNoProtocol = 'matrix.to/#/';
static const Duration _coolDown = Duration(milliseconds: 500);
void submitAction([_]) async {
controller.text = controller.text.trim();
if (!formKey.currentState!.validate()) return;
UrlLauncher(context, '$prefix${controller.text}').openMatrixToUrl();
}
String? validateForm(String? value) {
if (value!.isEmpty) {
return L10n.of(context)!.pleaseEnterAMatrixIdentifier;
void searchUsers([String? input]) async {
final searchTerm = input ?? controller.text;
if (searchTerm.isEmpty) {
_searchCoolDown?.cancel();
setState(() {
searchResponse = _searchCoolDown = null;
});
return;
}
if (!controller.text.isValidMatrixId ||
!supportedSigils.contains(controller.text.sigil)) {
return L10n.of(context)!.makeSureTheIdentifierIsValid;
}
if (controller.text == Matrix.of(context).client.userID) {
return L10n.of(context)!.youCannotInviteYourself;
}
return null;
_searchCoolDown?.cancel();
_searchCoolDown = Timer(_coolDown, () {
setState(() {
searchResponse =
Matrix.of(context).client.searchUserDirectory(searchTerm);
});
});
}
void inviteAction() => FluffyShare.shareInviteLink(context);
@ -81,6 +76,23 @@ class NewPrivateChatController extends State<NewPrivateChat> {
);
}
void copyUserId() async {
await Clipboard.setData(
ClipboardData(text: Matrix.of(context).client.userID!),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context)!.copiedToClipboard)),
);
}
void openUserModal(Profile profile) => showAdaptiveBottomSheet(
context: context,
builder: (c) => UserBottomSheet(
profile: profile,
outerContext: context,
),
);
@override
Widget build(BuildContext context) => NewPrivateChatView(this);
}

View file

@ -4,10 +4,15 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pages/new_private_chat/new_private_chat.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/utils/localized_exception_extension.dart';
import 'package:fluffychat/utils/url_launcher.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -16,10 +21,9 @@ class NewPrivateChatView extends StatelessWidget {
const NewPrivateChatView(this.controller, {super.key});
static const double _qrCodePadding = 8;
@override
Widget build(BuildContext context) {
final searchResponse = controller.searchResponse;
final qrCodeSize =
min(MediaQuery.of(context).size.width - 16, 256).toDouble();
return Scaffold(
@ -28,106 +32,221 @@ class NewPrivateChatView extends StatelessWidget {
title: Text(L10n.of(context)!.newChat),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
actions: [
Padding(
padding: const EdgeInsets.all(8.0),
child: TextButton(
onPressed: () => context.go('/rooms/newgroup'),
child: Text(
L10n.of(context)!.createGroup,
style:
TextStyle(color: Theme.of(context).colorScheme.secondary),
),
),
IconButton(
onPressed:
UrlLauncher(context, AppConfig.startChatTutorial).launchUrl,
icon: const Icon(Icons.info_outlined),
),
],
),
body: Column(
children: [
Expanded(
child: MaxWidthBody(
withFrame: false,
child: Container(
margin: const EdgeInsets.all(_qrCodePadding),
alignment: Alignment.center,
padding: const EdgeInsets.all(_qrCodePadding * 2),
child: Material(
borderRadius: BorderRadius.circular(12),
elevation: 10,
color: Colors.white,
shadowColor: Theme.of(context).appBarTheme.shadowColor,
clipBehavior: Clip.hardEdge,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
QrImageView(
data:
'https://matrix.to/#/${Matrix.of(context).client.userID}',
version: QrVersions.auto,
size: qrCodeSize,
),
TextButton.icon(
style: TextButton.styleFrom(
fixedSize:
Size.fromWidth(qrCodeSize - (2 * _qrCodePadding)),
foregroundColor: Colors.black,
body: MaxWidthBody(
withScrolling: false,
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: TextField(
controller: controller.controller,
onChanged: controller.searchUsers,
decoration: InputDecoration(
hintText: 'Search for @users...',
prefixIcon: searchResponse == null
? const Icon(Icons.search_outlined)
: FutureBuilder(
future: searchResponse,
builder: (context, snapshot) {
if (snapshot.connectionState !=
ConnectionState.done) {
return const Padding(
padding: EdgeInsets.all(10.0),
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator.adaptive(
strokeWidth: 1,
),
),
);
}
return const Icon(Icons.search_outlined);
},
),
suffixIcon: controller.controller.text.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear_outlined),
onPressed: () {
controller.controller.clear();
controller.searchUsers();
},
),
),
),
),
Expanded(
child: AnimatedCrossFade(
duration: FluffyThemes.animationDuration,
crossFadeState: searchResponse == null
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: ListView(
children: [
ListTile(
title: SelectableText.rich(
TextSpan(
children: [
TextSpan(
text: L10n.of(context)!.yourGlobalUserIdIs,
),
TextSpan(
text: Matrix.of(context).client.userID,
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
],
),
style: TextStyle(
color:
Theme.of(context).colorScheme.onPrimaryContainer,
fontSize: 14,
),
icon: Icon(Icons.adaptive.share_outlined),
label: Text(L10n.of(context)!.shareInviteLink),
onPressed: controller.inviteAction,
),
const SizedBox(height: 8),
if (PlatformInfos.isMobile) ...[
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
backgroundColor:
Theme.of(context).colorScheme.primaryContainer,
fixedSize: Size.fromWidth(
qrCodeSize - (2 * _qrCodePadding),
trailing: IconButton(
icon: Icon(
Icons.copy_outlined,
size: 16,
color:
Theme.of(context).colorScheme.onPrimaryContainer,
),
onPressed: controller.copyUserId,
),
),
//if (PlatformInfos.isMobile)
ListTile(
leading: CircleAvatar(
backgroundColor:
Theme.of(context).colorScheme.primaryContainer,
foregroundColor:
Theme.of(context).colorScheme.onPrimaryContainer,
child: const Icon(Icons.qr_code_scanner_outlined),
),
title: Text(L10n.of(context)!.scanQrCode),
onTap: controller.openScannerAction,
),
ListTile(
leading: CircleAvatar(
backgroundColor:
Theme.of(context).colorScheme.secondaryContainer,
foregroundColor:
Theme.of(context).colorScheme.onSecondaryContainer,
child: Icon(Icons.adaptive.share_outlined),
),
title: Text(L10n.of(context)!.shareInviteLink),
onTap: controller.inviteAction,
),
ListTile(
leading: CircleAvatar(
backgroundColor:
Theme.of(context).colorScheme.tertiaryContainer,
foregroundColor:
Theme.of(context).colorScheme.onTertiaryContainer,
child: const Icon(Icons.group_add_outlined),
),
title: Text(L10n.of(context)!.createGroup),
onTap: () => context.go('/rooms/newgroup'),
),
const SizedBox(height: 24),
Center(
child: Material(
borderRadius: BorderRadius.circular(12),
elevation: 10,
color: Colors.white,
shadowColor: Theme.of(context).appBarTheme.shadowColor,
clipBehavior: Clip.hardEdge,
child: QrImageView(
data:
'https://matrix.to/#/${Matrix.of(context).client.userID}',
version: QrVersions.auto,
size: qrCodeSize,
),
),
),
],
),
secondChild: FutureBuilder(
future: searchResponse,
builder: (context, snapshot) {
final result = snapshot.data;
final error = snapshot.error;
if (error != null) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
error.toLocalizedString(context),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
icon: const Icon(Icons.qr_code_scanner_outlined),
label: Text(L10n.of(context)!.scanQrCode),
onPressed: controller.openScannerAction,
),
const SizedBox(height: 8),
],
],
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: controller.searchUsers,
icon: const Icon(Icons.refresh_outlined),
label: Text(L10n.of(context)!.tryAgain),
),
],
);
}
if (result == null) {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
}
if (result.results.isEmpty) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.search_outlined, size: 86),
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
L10n.of(context)!.noUsersFoundWithQuery(
controller.controller.text,
),
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center,
),
),
],
);
}
return ListView.builder(
itemCount: result.results.length,
itemBuilder: (context, i) {
final contact = result.results[i];
final displayname = contact.displayName ??
contact.userId.localpart ??
contact.userId;
return ListTile(
leading: Avatar(
name: displayname,
mxContent: contact.avatarUrl,
),
title: Text(displayname),
subtitle: Text(contact.userId),
onTap: () => controller.openUserModal(contact),
);
},
);
},
),
),
),
),
MaxWidthBody(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Form(
key: controller.formKey,
child: TextFormField(
controller: controller.controller,
autocorrect: false,
textInputAction: TextInputAction.go,
focusNode: controller.textFieldFocus,
onFieldSubmitted: controller.submitAction,
validator: controller.validateForm,
inputFormatters: controller.removeMatrixToFormatters,
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
labelText: L10n.of(context)!.enterInviteLinkOrMatrixId,
hintText: '@username',
prefixText: NewPrivateChatController.prefixNoProtocol,
suffixIcon: IconButton(
icon: const Icon(Icons.send_outlined),
onPressed: controller.submitAction,
),
),
),
),
),
),
],
],
),
),
);
}