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",
|
"editTodo": "Edit todo",
|
||||||
"pleaseAddATitle": "Please add a title",
|
"pleaseAddATitle": "Please add a title",
|
||||||
"todoListChangedError": "Oops... The todo list has been changed while you edited it.",
|
"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';
|
'https://github.com/krille-chan/fluffychat/wiki/Push-Notifications-without-Google-Services';
|
||||||
static const String encryptionTutorial =
|
static const String encryptionTutorial =
|
||||||
'https://github.com/krille-chan/fluffychat/wiki/How-to-use-end-to-end-encryption-in-FluffyChat';
|
'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 appId = 'im.fluffychat.FluffyChat';
|
||||||
static const String appOpenUrlScheme = 'im.fluffychat';
|
static const String appOpenUrlScheme = 'im.fluffychat';
|
||||||
static String _webBaseUrl = 'https://fluffychat.im/web';
|
static String _webBaseUrl = 'https://fluffychat.im/web';
|
||||||
|
|
|
@ -54,7 +54,7 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
borderRadius: BorderRadius.circular(99),
|
borderRadius: BorderRadius.circular(99),
|
||||||
),
|
),
|
||||||
hintText: L10n.of(context)!.search,
|
hintText: L10n.of(context)!.searchChatsRooms,
|
||||||
floatingLabelBehavior: FloatingLabelBehavior.never,
|
floatingLabelBehavior: FloatingLabelBehavior.never,
|
||||||
prefixIcon: controller.isSearchMode
|
prefixIcon: controller.isSearchMode
|
||||||
? IconButton(
|
? IconButton(
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.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/new_private_chat_view.dart';
|
||||||
import 'package:fluffychat/pages/new_private_chat/qr_scanner_modal.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/adaptive_bottom_sheet.dart';
|
||||||
import 'package:fluffychat/utils/fluffy_share.dart';
|
import 'package:fluffychat/utils/fluffy_share.dart';
|
||||||
import 'package:fluffychat/utils/platform_infos.dart';
|
import 'package:fluffychat/utils/platform_infos.dart';
|
||||||
|
@ -23,38 +26,30 @@ class NewPrivateChat extends StatefulWidget {
|
||||||
class NewPrivateChatController extends State<NewPrivateChat> {
|
class NewPrivateChatController extends State<NewPrivateChat> {
|
||||||
final TextEditingController controller = TextEditingController();
|
final TextEditingController controller = TextEditingController();
|
||||||
final FocusNode textFieldFocus = FocusNode();
|
final FocusNode textFieldFocus = FocusNode();
|
||||||
final formKey = GlobalKey<FormState>();
|
|
||||||
bool loading = false;
|
|
||||||
|
|
||||||
// remove leading matrix.to from text field in order to simplify pasting
|
Future<SearchUserDirectoryResponse>? searchResponse;
|
||||||
final List<TextInputFormatter> removeMatrixToFormatters = [
|
|
||||||
FilteringTextInputFormatter.deny(NewPrivateChatController.prefix),
|
|
||||||
FilteringTextInputFormatter.deny(NewPrivateChatController.prefixNoProtocol),
|
|
||||||
];
|
|
||||||
|
|
||||||
static const Set<String> supportedSigils = {'@', '!', '#'};
|
Timer? _searchCoolDown;
|
||||||
|
|
||||||
static const String prefix = 'https://matrix.to/#/';
|
static const Duration _coolDown = Duration(milliseconds: 500);
|
||||||
static const String prefixNoProtocol = 'matrix.to/#/';
|
|
||||||
|
|
||||||
void submitAction([_]) async {
|
void searchUsers([String? input]) async {
|
||||||
controller.text = controller.text.trim();
|
final searchTerm = input ?? controller.text;
|
||||||
if (!formKey.currentState!.validate()) return;
|
if (searchTerm.isEmpty) {
|
||||||
UrlLauncher(context, '$prefix${controller.text}').openMatrixToUrl();
|
_searchCoolDown?.cancel();
|
||||||
}
|
setState(() {
|
||||||
|
searchResponse = _searchCoolDown = null;
|
||||||
String? validateForm(String? value) {
|
});
|
||||||
if (value!.isEmpty) {
|
return;
|
||||||
return L10n.of(context)!.pleaseEnterAMatrixIdentifier;
|
|
||||||
}
|
}
|
||||||
if (!controller.text.isValidMatrixId ||
|
|
||||||
!supportedSigils.contains(controller.text.sigil)) {
|
_searchCoolDown?.cancel();
|
||||||
return L10n.of(context)!.makeSureTheIdentifierIsValid;
|
_searchCoolDown = Timer(_coolDown, () {
|
||||||
}
|
setState(() {
|
||||||
if (controller.text == Matrix.of(context).client.userID) {
|
searchResponse =
|
||||||
return L10n.of(context)!.youCannotInviteYourself;
|
Matrix.of(context).client.searchUserDirectory(searchTerm);
|
||||||
}
|
});
|
||||||
return null;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void inviteAction() => FluffyShare.shareInviteLink(context);
|
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
|
@override
|
||||||
Widget build(BuildContext context) => NewPrivateChatView(this);
|
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:flutter_gen/gen_l10n/l10n.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:qr_flutter/qr_flutter.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/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/layouts/max_width_body.dart';
|
||||||
import 'package:fluffychat/widgets/matrix.dart';
|
import 'package:fluffychat/widgets/matrix.dart';
|
||||||
|
|
||||||
|
@ -16,10 +21,9 @@ class NewPrivateChatView extends StatelessWidget {
|
||||||
|
|
||||||
const NewPrivateChatView(this.controller, {super.key});
|
const NewPrivateChatView(this.controller, {super.key});
|
||||||
|
|
||||||
static const double _qrCodePadding = 8;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final searchResponse = controller.searchResponse;
|
||||||
final qrCodeSize =
|
final qrCodeSize =
|
||||||
min(MediaQuery.of(context).size.width - 16, 256).toDouble();
|
min(MediaQuery.of(context).size.width - 16, 256).toDouble();
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
@ -28,106 +32,221 @@ class NewPrivateChatView extends StatelessWidget {
|
||||||
title: Text(L10n.of(context)!.newChat),
|
title: Text(L10n.of(context)!.newChat),
|
||||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||||
actions: [
|
actions: [
|
||||||
Padding(
|
IconButton(
|
||||||
padding: const EdgeInsets.all(8.0),
|
onPressed:
|
||||||
child: TextButton(
|
UrlLauncher(context, AppConfig.startChatTutorial).launchUrl,
|
||||||
onPressed: () => context.go('/rooms/newgroup'),
|
icon: const Icon(Icons.info_outlined),
|
||||||
child: Text(
|
|
||||||
L10n.of(context)!.createGroup,
|
|
||||||
style:
|
|
||||||
TextStyle(color: Theme.of(context).colorScheme.secondary),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: MaxWidthBody(
|
||||||
children: [
|
withScrolling: false,
|
||||||
Expanded(
|
child: Column(
|
||||||
child: MaxWidthBody(
|
children: [
|
||||||
withFrame: false,
|
Padding(
|
||||||
child: Container(
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
margin: const EdgeInsets.all(_qrCodePadding),
|
child: TextField(
|
||||||
alignment: Alignment.center,
|
controller: controller.controller,
|
||||||
padding: const EdgeInsets.all(_qrCodePadding * 2),
|
onChanged: controller.searchUsers,
|
||||||
child: Material(
|
decoration: InputDecoration(
|
||||||
borderRadius: BorderRadius.circular(12),
|
hintText: 'Search for @users...',
|
||||||
elevation: 10,
|
prefixIcon: searchResponse == null
|
||||||
color: Colors.white,
|
? const Icon(Icons.search_outlined)
|
||||||
shadowColor: Theme.of(context).appBarTheme.shadowColor,
|
: FutureBuilder(
|
||||||
clipBehavior: Clip.hardEdge,
|
future: searchResponse,
|
||||||
child: Column(
|
builder: (context, snapshot) {
|
||||||
mainAxisSize: MainAxisSize.min,
|
if (snapshot.connectionState !=
|
||||||
children: [
|
ConnectionState.done) {
|
||||||
QrImageView(
|
return const Padding(
|
||||||
data:
|
padding: EdgeInsets.all(10.0),
|
||||||
'https://matrix.to/#/${Matrix.of(context).client.userID}',
|
child: SizedBox.square(
|
||||||
version: QrVersions.auto,
|
dimension: 24,
|
||||||
size: qrCodeSize,
|
child: CircularProgressIndicator.adaptive(
|
||||||
),
|
strokeWidth: 1,
|
||||||
TextButton.icon(
|
),
|
||||||
style: TextButton.styleFrom(
|
),
|
||||||
fixedSize:
|
);
|
||||||
Size.fromWidth(qrCodeSize - (2 * _qrCodePadding)),
|
}
|
||||||
foregroundColor: Colors.black,
|
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),
|
trailing: IconButton(
|
||||||
if (PlatformInfos.isMobile) ...[
|
icon: Icon(
|
||||||
OutlinedButton.icon(
|
Icons.copy_outlined,
|
||||||
style: OutlinedButton.styleFrom(
|
size: 16,
|
||||||
backgroundColor:
|
color:
|
||||||
Theme.of(context).colorScheme.primaryContainer,
|
Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
fixedSize: Size.fromWidth(
|
),
|
||||||
qrCodeSize - (2 * _qrCodePadding),
|
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),
|
const SizedBox(height: 12),
|
||||||
label: Text(L10n.of(context)!.scanQrCode),
|
OutlinedButton.icon(
|
||||||
onPressed: controller.openScannerAction,
|
onPressed: controller.searchUsers,
|
||||||
),
|
icon: const Icon(Icons.refresh_outlined),
|
||||||
const SizedBox(height: 8),
|
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