Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions integration_test/flows/keyboard_shortcuts.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

import '../utils/fluffy_chat_tester.dart';
import 'auth_flows.dart';
import 'chat_flows.dart';

Future<void> keyboardShortcuts(WidgetTester widgetTester) => widgetTester
.startFluffyChatTest()
.then((tester) => tester._keyboardShortcuts());

extension on FluffyChatTester {
Future<void> _keyboardShortcuts() async {
await ensureLoggedIn();
await ensureGroupChatCreated();
await tapOn(ChatFlows.groupChatName);

// Focus the input field and enter text
final inputField = find.byKey(const Key('chat_input_field'));
await tester.tap(inputField);
await tester.pumpAndSettle();
await tester.enterText(inputField, 'hello');
await tester.pumpAndSettle();

// Select all text with Ctrl+A
await sendKeyCombo(LogicalKeyboardKey.control, LogicalKeyboardKey.keyA);

// Bold with Ctrl+B
await sendKeyCombo(LogicalKeyboardKey.control, LogicalKeyboardKey.keyB);

// Verify the text field now contains **hello**
final textField = tester.widget<EditableText>(
find.descendant(of: inputField, matching: find.byType(EditableText)),
);
expect(textField.controller.text, '**hello**');

// Clear and test italic: type new text, select, Ctrl+I
await tester.enterText(inputField, 'world');
await tester.pumpAndSettle();
await sendKeyCombo(LogicalKeyboardKey.control, LogicalKeyboardKey.keyA);
await sendKeyCombo(LogicalKeyboardKey.control, LogicalKeyboardKey.keyI);
expect(textField.controller.text, '*world*');

// Clear and test with no selection (cursor only): Ctrl+B inserts ****
await tester.enterText(inputField, '');
await tester.pumpAndSettle();
await sendKeyCombo(LogicalKeyboardKey.control, LogicalKeyboardKey.keyB);
expect(textField.controller.text, '****');
}
}
2 changes: 2 additions & 0 deletions integration_test/mobile_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:integration_test/integration_test.dart';
import 'flows/auth_flows.dart';
import 'flows/basic_messaging.dart';
import 'flows/chat_flows.dart';
import 'flows/keyboard_shortcuts.dart';
import 'flows/login_and_chat_backup.dart';
import 'flows/multi_account.dart';

Expand All @@ -13,6 +14,7 @@ void main() {
group('FluffyChat Integration Tests', () {
testWidgets('Login and logout flow', loginAndChatBackup);
testWidgets('Basic Messaging', basicMessaging);
testWidgets('Keyboard shortcuts', keyboardShortcuts);
testWidgets('Multi-Account', multiAccount);
testWidgets('Archive chats', archiveChats);
testWidgets('Final logout', finalLogout);
Expand Down
13 changes: 13 additions & 0 deletions integration_test/utils/fluffy_chat_tester.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:fluffychat/main.dart' as app;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';

Expand Down Expand Up @@ -91,6 +92,18 @@ extension type FluffyChatTester(WidgetTester tester) {
);
await tester.pumpAndSettle();
}

Future<void> sendKeyCombo(
LogicalKeyboardKey modifier,
LogicalKeyboardKey key,
) async {
_print('➕ Sending ${modifier.keyLabel}+${key.keyLabel}');
await tester.sendKeyDownEvent(modifier);
await tester.sendKeyDownEvent(key);
await tester.sendKeyUpEvent(key);
await tester.sendKeyUpEvent(modifier);
await tester.pumpAndSettle();
}
}

extension on Object {
Expand Down
74 changes: 74 additions & 0 deletions lib/pages/chat/chat.dart
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ class ChatController extends State<ChatPageWithRoom>

bool showEmojiPicker = false;

int emojiPickerInitialTab = 0;

final UndoHistoryController undoController = UndoHistoryController();

String? get threadLastEventId {
final threadId = activeThreadId;
if (threadId == null) return null;
Expand Down Expand Up @@ -308,7 +312,76 @@ class ChatController extends State<ChatPageWithRoom>
);
}

void _toggleEmojiPickerTab(int tab) {
if (showEmojiPicker && emojiPickerInitialTab == tab) {
emojiPickerAction();
} else if (showEmojiPicker) {
// Already open on a different tab — close and reopen on the new tab
setState(() {
showEmojiPicker = false;
emojiPickerInitialTab = tab;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
emojiPickerAction();
});
} else {
emojiPickerInitialTab = tab;
emojiPickerAction();
}
}

void _wrapSelectedText(String prefix, String suffix) {
final selection = sendController.selection;
final text = sendController.text;
final selectedText = selection.textInside(text);

final replacement = '$prefix$selectedText$suffix';
final newText = text.replaceRange(selection.start, selection.end, replacement);
final cursorOffset = selectedText.isEmpty
? selection.start + prefix.length
: selection.start + replacement.length;

sendController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(offset: cursorOffset),
);
}

KeyEventResult _customEnterKeyHandling(FocusNode node, KeyEvent evt) {
if (evt is KeyDownEvent &&
(HardwareKeyboard.instance.isControlPressed ||
HardwareKeyboard.instance.isMetaPressed)) {
if (evt.logicalKey == LogicalKeyboardKey.keyB) {
_wrapSelectedText('**', '**');
return KeyEventResult.handled;
}
if (evt.logicalKey == LogicalKeyboardKey.keyI) {
_wrapSelectedText('*', '*');
return KeyEventResult.handled;
}
if (evt.logicalKey == LogicalKeyboardKey.keyE) {
_toggleEmojiPickerTab(0);
return KeyEventResult.handled;
}
if (evt.logicalKey == LogicalKeyboardKey.keyS) {
_toggleEmojiPickerTab(1);
return KeyEventResult.handled;
}
if (evt.logicalKey == LogicalKeyboardKey.keyY) {
undoController.redo();
return KeyEventResult.handled;
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks very handy. 🤔 Actually I would like to prefer having an integration test for this as well to make sure this never breaks. Is this possible on Android?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it...should be...I think....I'll test it today 😅

}

if (evt is KeyDownEvent &&
evt.logicalKey == LogicalKeyboardKey.escape) {
if (showEmojiPicker) {
hideEmojiPicker();
inputFocus.requestFocus();
return KeyEventResult.handled;
}
}

if (!HardwareKeyboard.instance.isShiftPressed &&
evt.logicalKey.keyLabel == 'Enter' &&
AppSettings.sendOnEnter.value) {
Expand Down Expand Up @@ -561,6 +634,7 @@ class ChatController extends State<ChatPageWithRoom>
timeline?.cancelSubscriptions();
timeline = null;
inputFocus.removeListener(_inputFocusListener);
undoController.dispose();
if (currentlyTyping) room.setTyping(false);
super.dispose();
}
Expand Down
1 change: 1 addition & 0 deletions lib/pages/chat/chat_emoji_picker.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class ChatEmojiPicker extends StatelessWidget {
: 0,
child: controller.showEmojiPicker
? DefaultTabController(
initialIndex: controller.emojiPickerInitialTab,
length: 2,
child: Column(
children: [
Expand Down
1 change: 1 addition & 0 deletions lib/pages/chat/chat_input_row.dart
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ class ChatInputRow extends StatelessWidget {
filled: false,
),
onChanged: controller.onInputBarChanged,
undoController: controller.undoController,
suggestionEmojis:
getDefaultEmojiLocale(
AppSettings.emojiSuggestionLocale.value.isNotEmpty
Expand Down
3 changes: 3 additions & 0 deletions lib/pages/chat/input_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class InputBar extends StatelessWidget {
final bool? autofocus;
final bool readOnly;
final List<Emoji> suggestionEmojis;
final UndoHistoryController? undoController;

const InputBar({
required this.room,
Expand All @@ -44,6 +45,7 @@ class InputBar extends StatelessWidget {
this.textInputAction,
this.readOnly = false,
required this.suggestionEmojis,
this.undoController,
super.key,
});

Expand Down Expand Up @@ -391,6 +393,7 @@ class InputBar extends StatelessWidget {
fieldViewBuilder: (context, controller, focusNode, _) => TextField(
controller: controller,
focusNode: focusNode,
undoController: undoController,
readOnly: readOnly,
onEditingComplete: () {
// To not lose focus on iOS:
Expand Down
Loading