Chat enter to send

This commit is contained in:
LittleSheep 2025-05-24 02:38:42 +08:00
parent d257a9697b
commit 4f9bf960d9
7 changed files with 363 additions and 91 deletions

View File

@ -271,5 +271,12 @@
"unreadMessages": { "unreadMessages": {
"one": "{} unread message", "one": "{} unread message",
"other": "{} unread messages" "other": "{} unread messages"
} },
"settingsRealmCompactView": "Compact Realm View",
"settingsMixedFeed": "Mixed Feed",
"settingsAutoTranslate": "Auto Translate",
"settingsHideBottomNav": "Hide Bottom Navigation",
"settingsSoundEffects": "Sound Effects",
"settingsAprilFoolFeatures": "April Fool Features",
"settingsEnterToSend": "Enter to Send"
} }

View File

@ -6,7 +6,7 @@ part of 'chat_summary.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$chatSummaryHash() => r'fa48d381f489f90055fb728f7e0fda6f8ef49d15'; String _$chatSummaryHash() => r'19aad48b5fabb33a76b742400d3b738ceb81c40c';
/// See also [ChatSummary]. /// See also [ChatSummary].
@ProviderFor(ChatSummary) @ProviderFor(ChatSummary)

View File

@ -1,7 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
part 'config.freezed.dart';
const kTokenPairStoreKey = 'dyn_user_tk'; const kTokenPairStoreKey = 'dyn_user_tk';
const kNetworkServerDefault = 'https://nt.solian.app'; const kNetworkServerDefault = 'https://nt.solian.app';
@ -21,6 +24,7 @@ const kAppHideBottomNav = 'app_hide_bottom_nav';
const kAppSoundEffects = 'app_sound_effects'; const kAppSoundEffects = 'app_sound_effects';
const kAppAprilFoolFeatures = 'app_april_fool_features'; const kAppAprilFoolFeatures = 'app_april_fool_features';
const kAppWindowSize = 'app_window_size'; const kAppWindowSize = 'app_window_size';
const kAppEnterToSend = 'app_enter_to_send';
const Map<String, FilterQuality> kImageQualityLevel = { const Map<String, FilterQuality> kImageQualityLevel = {
'settingsImageQualityLowest': FilterQuality.none, 'settingsImageQualityLowest': FilterQuality.none,
@ -46,40 +50,17 @@ final serverUrlProvider = Provider<String>((ref) {
return prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault; return prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
}); });
class AppSettings { @freezed
final bool realmCompactView; abstract class AppSettings with _$AppSettings {
final bool mixedFeed; const factory AppSettings({
final bool autoTranslate; required bool realmCompactView,
final bool hideBottomNav; required bool mixedFeed,
final bool soundEffects; required bool autoTranslate,
final bool aprilFoolFeatures; required bool hideBottomNav,
required bool soundEffects,
AppSettings({ required bool aprilFoolFeatures,
required this.realmCompactView, required bool enterToSend,
required this.mixedFeed, }) = _AppSettings;
required this.autoTranslate,
required this.hideBottomNav,
required this.soundEffects,
required this.aprilFoolFeatures,
});
AppSettings copyWith({
bool? realmCompactView,
bool? mixedFeed,
bool? autoTranslate,
bool? hideBottomNav,
bool? soundEffects,
bool? aprilFoolFeatures,
}) {
return AppSettings(
realmCompactView: realmCompactView ?? this.realmCompactView,
mixedFeed: mixedFeed ?? this.mixedFeed,
autoTranslate: autoTranslate ?? this.autoTranslate,
hideBottomNav: hideBottomNav ?? this.hideBottomNav,
soundEffects: soundEffects ?? this.soundEffects,
aprilFoolFeatures: aprilFoolFeatures ?? this.aprilFoolFeatures,
);
}
} }
class AppSettingsNotifier extends StateNotifier<AppSettings> { class AppSettingsNotifier extends StateNotifier<AppSettings> {
@ -94,6 +75,7 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
hideBottomNav: prefs.getBool(kAppHideBottomNav) ?? false, hideBottomNav: prefs.getBool(kAppHideBottomNav) ?? false,
soundEffects: prefs.getBool(kAppSoundEffects) ?? true, soundEffects: prefs.getBool(kAppSoundEffects) ?? true,
aprilFoolFeatures: prefs.getBool(kAppAprilFoolFeatures) ?? true, aprilFoolFeatures: prefs.getBool(kAppAprilFoolFeatures) ?? true,
enterToSend: prefs.getBool(kAppEnterToSend) ?? true,
), ),
); );
@ -126,6 +108,11 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
prefs.setBool(kAppAprilFoolFeatures, value); prefs.setBool(kAppAprilFoolFeatures, value);
state = state.copyWith(aprilFoolFeatures: value); state = state.copyWith(aprilFoolFeatures: value);
} }
void setEnterToSend(bool value) {
prefs.setBool(kAppEnterToSend, value);
state = state.copyWith(enterToSend: value);
}
} }
final appSettingsProvider = final appSettingsProvider =

View File

@ -0,0 +1,160 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'config.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$AppSettings {
bool get realmCompactView; bool get mixedFeed; bool get autoTranslate; bool get hideBottomNav; bool get soundEffects; bool get aprilFoolFeatures; bool get enterToSend;
/// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$AppSettingsCopyWith<AppSettings> get copyWith => _$AppSettingsCopyWithImpl<AppSettings>(this as AppSettings, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is AppSettings&&(identical(other.realmCompactView, realmCompactView) || other.realmCompactView == realmCompactView)&&(identical(other.mixedFeed, mixedFeed) || other.mixedFeed == mixedFeed)&&(identical(other.autoTranslate, autoTranslate) || other.autoTranslate == autoTranslate)&&(identical(other.hideBottomNav, hideBottomNav) || other.hideBottomNav == hideBottomNav)&&(identical(other.soundEffects, soundEffects) || other.soundEffects == soundEffects)&&(identical(other.aprilFoolFeatures, aprilFoolFeatures) || other.aprilFoolFeatures == aprilFoolFeatures)&&(identical(other.enterToSend, enterToSend) || other.enterToSend == enterToSend));
}
@override
int get hashCode => Object.hash(runtimeType,realmCompactView,mixedFeed,autoTranslate,hideBottomNav,soundEffects,aprilFoolFeatures,enterToSend);
@override
String toString() {
return 'AppSettings(realmCompactView: $realmCompactView, mixedFeed: $mixedFeed, autoTranslate: $autoTranslate, hideBottomNav: $hideBottomNav, soundEffects: $soundEffects, aprilFoolFeatures: $aprilFoolFeatures, enterToSend: $enterToSend)';
}
}
/// @nodoc
abstract mixin class $AppSettingsCopyWith<$Res> {
factory $AppSettingsCopyWith(AppSettings value, $Res Function(AppSettings) _then) = _$AppSettingsCopyWithImpl;
@useResult
$Res call({
bool realmCompactView, bool mixedFeed, bool autoTranslate, bool hideBottomNav, bool soundEffects, bool aprilFoolFeatures, bool enterToSend
});
}
/// @nodoc
class _$AppSettingsCopyWithImpl<$Res>
implements $AppSettingsCopyWith<$Res> {
_$AppSettingsCopyWithImpl(this._self, this._then);
final AppSettings _self;
final $Res Function(AppSettings) _then;
/// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? realmCompactView = null,Object? mixedFeed = null,Object? autoTranslate = null,Object? hideBottomNav = null,Object? soundEffects = null,Object? aprilFoolFeatures = null,Object? enterToSend = null,}) {
return _then(_self.copyWith(
realmCompactView: null == realmCompactView ? _self.realmCompactView : realmCompactView // ignore: cast_nullable_to_non_nullable
as bool,mixedFeed: null == mixedFeed ? _self.mixedFeed : mixedFeed // ignore: cast_nullable_to_non_nullable
as bool,autoTranslate: null == autoTranslate ? _self.autoTranslate : autoTranslate // ignore: cast_nullable_to_non_nullable
as bool,hideBottomNav: null == hideBottomNav ? _self.hideBottomNav : hideBottomNav // ignore: cast_nullable_to_non_nullable
as bool,soundEffects: null == soundEffects ? _self.soundEffects : soundEffects // ignore: cast_nullable_to_non_nullable
as bool,aprilFoolFeatures: null == aprilFoolFeatures ? _self.aprilFoolFeatures : aprilFoolFeatures // ignore: cast_nullable_to_non_nullable
as bool,enterToSend: null == enterToSend ? _self.enterToSend : enterToSend // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// @nodoc
class _AppSettings implements AppSettings {
const _AppSettings({required this.realmCompactView, required this.mixedFeed, required this.autoTranslate, required this.hideBottomNav, required this.soundEffects, required this.aprilFoolFeatures, required this.enterToSend});
@override final bool realmCompactView;
@override final bool mixedFeed;
@override final bool autoTranslate;
@override final bool hideBottomNav;
@override final bool soundEffects;
@override final bool aprilFoolFeatures;
@override final bool enterToSend;
/// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$AppSettingsCopyWith<_AppSettings> get copyWith => __$AppSettingsCopyWithImpl<_AppSettings>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppSettings&&(identical(other.realmCompactView, realmCompactView) || other.realmCompactView == realmCompactView)&&(identical(other.mixedFeed, mixedFeed) || other.mixedFeed == mixedFeed)&&(identical(other.autoTranslate, autoTranslate) || other.autoTranslate == autoTranslate)&&(identical(other.hideBottomNav, hideBottomNav) || other.hideBottomNav == hideBottomNav)&&(identical(other.soundEffects, soundEffects) || other.soundEffects == soundEffects)&&(identical(other.aprilFoolFeatures, aprilFoolFeatures) || other.aprilFoolFeatures == aprilFoolFeatures)&&(identical(other.enterToSend, enterToSend) || other.enterToSend == enterToSend));
}
@override
int get hashCode => Object.hash(runtimeType,realmCompactView,mixedFeed,autoTranslate,hideBottomNav,soundEffects,aprilFoolFeatures,enterToSend);
@override
String toString() {
return 'AppSettings(realmCompactView: $realmCompactView, mixedFeed: $mixedFeed, autoTranslate: $autoTranslate, hideBottomNav: $hideBottomNav, soundEffects: $soundEffects, aprilFoolFeatures: $aprilFoolFeatures, enterToSend: $enterToSend)';
}
}
/// @nodoc
abstract mixin class _$AppSettingsCopyWith<$Res> implements $AppSettingsCopyWith<$Res> {
factory _$AppSettingsCopyWith(_AppSettings value, $Res Function(_AppSettings) _then) = __$AppSettingsCopyWithImpl;
@override @useResult
$Res call({
bool realmCompactView, bool mixedFeed, bool autoTranslate, bool hideBottomNav, bool soundEffects, bool aprilFoolFeatures, bool enterToSend
});
}
/// @nodoc
class __$AppSettingsCopyWithImpl<$Res>
implements _$AppSettingsCopyWith<$Res> {
__$AppSettingsCopyWithImpl(this._self, this._then);
final _AppSettings _self;
final $Res Function(_AppSettings) _then;
/// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? realmCompactView = null,Object? mixedFeed = null,Object? autoTranslate = null,Object? hideBottomNav = null,Object? soundEffects = null,Object? aprilFoolFeatures = null,Object? enterToSend = null,}) {
return _then(_AppSettings(
realmCompactView: null == realmCompactView ? _self.realmCompactView : realmCompactView // ignore: cast_nullable_to_non_nullable
as bool,mixedFeed: null == mixedFeed ? _self.mixedFeed : mixedFeed // ignore: cast_nullable_to_non_nullable
as bool,autoTranslate: null == autoTranslate ? _self.autoTranslate : autoTranslate // ignore: cast_nullable_to_non_nullable
as bool,hideBottomNav: null == hideBottomNav ? _self.hideBottomNav : hideBottomNav // ignore: cast_nullable_to_non_nullable
as bool,soundEffects: null == soundEffects ? _self.soundEffects : soundEffects // ignore: cast_nullable_to_non_nullable
as bool,aprilFoolFeatures: null == aprilFoolFeatures ? _self.aprilFoolFeatures : aprilFoolFeatures // ignore: cast_nullable_to_non_nullable
as bool,enterToSend: null == enterToSend ? _self.enterToSend : enterToSend // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
// dart format on

View File

@ -93,10 +93,13 @@ class ChatRoomListTile extends HookConsumerWidget {
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
), ),
Text( Align(
alignment: Alignment.centerRight,
child: Text(
RelativeTime(context).format(data.lastMessage.createdAt), RelativeTime(context).format(data.lastMessage.createdAt),
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
),
], ],
), ),
], ],
@ -117,24 +120,14 @@ class ChatRoomListTile extends HookConsumerWidget {
); );
} }
Widget buildTrailing() {
if (trailing != null) return trailing!;
return summary.when(
data: (data) {
if (data == null || data.unreadCount == 0) {
return const SizedBox.shrink();
}
return Badge(label: Text(data.unreadCount.toString()));
},
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
);
}
return ListTile( return ListTile(
leading: leading: Badge(
isLabelVisible: summary.when(
data: (data) => (data?.unreadCount ?? 0) > 0,
loading: () => false,
error: (_, __) => false,
),
child:
(isDirect && room.pictureId == null) (isDirect && room.pictureId == null)
? SplitAvatarWidget( ? SplitAvatarWidget(
filesId: filesId:
@ -145,13 +138,13 @@ class ChatRoomListTile extends HookConsumerWidget {
: room.pictureId == null : room.pictureId == null
? CircleAvatar(child: Text(room.name![0].toUpperCase())) ? CircleAvatar(child: Text(room.name![0].toUpperCase()))
: ProfilePictureWidget(fileId: room.pictureId), : ProfilePictureWidget(fileId: room.pictureId),
),
title: Text( title: Text(
(isDirect && room.name == null) (isDirect && room.name == null)
? room.members!.map((e) => e.account.nick).join(', ') ? room.members!.map((e) => e.account.nick).join(', ')
: room.name ?? '', : room.name ?? '',
), ),
subtitle: buildSubtitle(), subtitle: buildSubtitle(),
trailing: buildTrailing(),
onTap: () async { onTap: () async {
// Clear unread count if there are unread messages // Clear unread count if there are unread messages
final summary = await ref.read(chatSummaryProvider.future); final summary = await ref.read(chatSummaryProvider.future);

View File

@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -676,7 +677,7 @@ class ChatRoomScreen extends HookConsumerWidget {
} }
} }
class _ChatInput extends StatelessWidget { class _ChatInput extends ConsumerWidget {
final TextEditingController messageController; final TextEditingController messageController;
final SnChatRoom chatRoom; final SnChatRoom chatRoom;
final VoidCallback onSend; final VoidCallback onSend;
@ -705,8 +706,26 @@ class _ChatInput extends StatelessWidget {
required this.onMoveAttachment, required this.onMoveAttachment,
}); });
void _handleKeyPress(BuildContext context, WidgetRef ref, RawKeyEvent event) {
if (event is! RawKeyDownEvent) return;
final enterToSend = ref.read(appSettingsProvider).enterToSend;
final isEnter = event.logicalKey == LogicalKeyboardKey.enter;
final isModifierPressed = event.isMetaPressed || event.isControlPressed;
if (isEnter) {
if (enterToSend && !isModifierPressed) {
onSend();
} else if (!enterToSend && isModifierPressed) {
onSend();
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final enterToSend = ref.watch(appSettingsProvider).enterToSend;
return Material( return Material(
elevation: 8, elevation: 8,
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
@ -806,8 +825,20 @@ class _ChatInput extends StatelessWidget {
], ],
), ),
Expanded( Expanded(
child: RawKeyboardListener(
focusNode: FocusNode(),
onKey: (event) => _handleKeyPress(context, ref, event),
child: TextField( child: TextField(
controller: messageController, controller: messageController,
inputFormatters: [
if (enterToSend)
TextInputFormatter.withFunction((oldValue, newValue) {
if (newValue.text.endsWith('\n')) {
return oldValue;
}
return newValue;
}),
],
decoration: InputDecoration( decoration: InputDecoration(
hintText: hintText:
(chatRoom.type == 1 && chatRoom.name == null) (chatRoom.type == 1 && chatRoom.name == null)
@ -829,7 +860,7 @@ class _ChatInput extends StatelessWidget {
maxLines: null, maxLines: null,
onTapOutside: onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(), (_) => FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (_) => onSend(), ),
), ),
), ),
IconButton( IconButton(

View File

@ -26,6 +26,7 @@ class SettingsScreen extends HookConsumerWidget {
final serverUrl = ref.watch(serverUrlProvider); final serverUrl = ref.watch(serverUrlProvider);
final prefs = ref.watch(sharedPreferencesProvider); final prefs = ref.watch(sharedPreferencesProvider);
final controller = TextEditingController(text: serverUrl); final controller = TextEditingController(text: serverUrl);
final settings = ref.watch(appSettingsProvider);
final docBasepath = useState<String?>(null); final docBasepath = useState<String?>(null);
@ -174,6 +175,99 @@ class SettingsScreen extends HookConsumerWidget {
); );
}, },
), ),
const Divider(),
ListTile(
minLeadingWidth: 48,
title: Text('settingsRealmCompactView').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.view_compact),
trailing: Switch(
value: settings.realmCompactView,
onChanged: (value) {
ref
.read(appSettingsProvider.notifier)
.setRealmCompactView(value);
},
),
),
ListTile(
minLeadingWidth: 48,
title: Text('settingsMixedFeed').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.merge),
trailing: Switch(
value: settings.mixedFeed,
onChanged: (value) {
ref.read(appSettingsProvider.notifier).setMixedFeed(value);
},
),
),
ListTile(
minLeadingWidth: 48,
title: Text('settingsAutoTranslate').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.translate),
trailing: Switch(
value: settings.autoTranslate,
onChanged: (value) {
ref
.read(appSettingsProvider.notifier)
.setAutoTranslate(value);
},
),
),
ListTile(
minLeadingWidth: 48,
title: Text('settingsHideBottomNav').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.navigation),
trailing: Switch(
value: settings.hideBottomNav,
onChanged: (value) {
ref
.read(appSettingsProvider.notifier)
.setHideBottomNav(value);
},
),
),
ListTile(
minLeadingWidth: 48,
title: Text('settingsSoundEffects').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.volume_up),
trailing: Switch(
value: settings.soundEffects,
onChanged: (value) {
ref.read(appSettingsProvider.notifier).setSoundEffects(value);
},
),
),
ListTile(
minLeadingWidth: 48,
title: Text('settingsAprilFoolFeatures').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.celebration),
trailing: Switch(
value: settings.aprilFoolFeatures,
onChanged: (value) {
ref
.read(appSettingsProvider.notifier)
.setAprilFoolFeatures(value);
},
),
),
ListTile(
minLeadingWidth: 48,
title: Text('settingsEnterToSend').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.send),
trailing: Switch(
value: settings.enterToSend,
onChanged: (value) {
ref.read(appSettingsProvider.notifier).setEnterToSend(value);
},
),
),
], ],
), ),
), ),