mirror of
https://github.com/krille-chan/fluffychat
synced 2024-09-10 12:25:11 +00:00
feat: Improved UX design for new chat page
This commit is contained in:
parent
4588d82dad
commit
f58b9b814a
5 changed files with 264 additions and 122 deletions
|
@ -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..."
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue