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": {
"one": "{} unread message",
"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
// **************************************************************************
String _$chatSummaryHash() => r'fa48d381f489f90055fb728f7e0fda6f8ef49d15';
String _$chatSummaryHash() => r'19aad48b5fabb33a76b742400d3b738ceb81c40c';
/// See also [ChatSummary].
@ProviderFor(ChatSummary)

View File

@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'config.freezed.dart';
const kTokenPairStoreKey = 'dyn_user_tk';
const kNetworkServerDefault = 'https://nt.solian.app';
@ -21,6 +24,7 @@ const kAppHideBottomNav = 'app_hide_bottom_nav';
const kAppSoundEffects = 'app_sound_effects';
const kAppAprilFoolFeatures = 'app_april_fool_features';
const kAppWindowSize = 'app_window_size';
const kAppEnterToSend = 'app_enter_to_send';
const Map<String, FilterQuality> kImageQualityLevel = {
'settingsImageQualityLowest': FilterQuality.none,
@ -46,40 +50,17 @@ final serverUrlProvider = Provider<String>((ref) {
return prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
});
class AppSettings {
final bool realmCompactView;
final bool mixedFeed;
final bool autoTranslate;
final bool hideBottomNav;
final bool soundEffects;
final bool aprilFoolFeatures;
AppSettings({
required this.realmCompactView,
required this.mixedFeed,
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,
);
}
@freezed
abstract class AppSettings with _$AppSettings {
const factory AppSettings({
required bool realmCompactView,
required bool mixedFeed,
required bool autoTranslate,
required bool hideBottomNav,
required bool soundEffects,
required bool aprilFoolFeatures,
required bool enterToSend,
}) = _AppSettings;
}
class AppSettingsNotifier extends StateNotifier<AppSettings> {
@ -94,6 +75,7 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
hideBottomNav: prefs.getBool(kAppHideBottomNav) ?? false,
soundEffects: prefs.getBool(kAppSoundEffects) ?? true,
aprilFoolFeatures: prefs.getBool(kAppAprilFoolFeatures) ?? true,
enterToSend: prefs.getBool(kAppEnterToSend) ?? true,
),
);
@ -126,6 +108,11 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
prefs.setBool(kAppAprilFoolFeatures, value);
state = state.copyWith(aprilFoolFeatures: value);
}
void setEnterToSend(bool value) {
prefs.setBool(kAppEnterToSend, value);
state = state.copyWith(enterToSend: value);
}
}
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,9 +93,12 @@ class ChatRoomListTile extends HookConsumerWidget {
style: Theme.of(context).textTheme.bodySmall,
),
),
Text(
RelativeTime(context).format(data.lastMessage.createdAt),
style: Theme.of(context).textTheme.bodySmall,
Align(
alignment: Alignment.centerRight,
child: Text(
RelativeTime(context).format(data.lastMessage.createdAt),
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
@ -117,41 +120,31 @@ 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(
leading:
(isDirect && room.pictureId == null)
? SplitAvatarWidget(
filesId:
room.members!
.map((e) => e.account.profile.pictureId)
.toList(),
)
: room.pictureId == null
? CircleAvatar(child: Text(room.name![0].toUpperCase()))
: ProfilePictureWidget(fileId: room.pictureId),
leading: Badge(
isLabelVisible: summary.when(
data: (data) => (data?.unreadCount ?? 0) > 0,
loading: () => false,
error: (_, __) => false,
),
child:
(isDirect && room.pictureId == null)
? SplitAvatarWidget(
filesId:
room.members!
.map((e) => e.account.profile.pictureId)
.toList(),
)
: room.pictureId == null
? CircleAvatar(child: Text(room.name![0].toUpperCase()))
: ProfilePictureWidget(fileId: room.pictureId),
),
title: Text(
(isDirect && room.name == null)
? room.members!.map((e) => e.account.nick).join(', ')
: room.name ?? '',
),
subtitle: buildSubtitle(),
trailing: buildTrailing(),
onTap: () async {
// Clear unread count if there are unread messages
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:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.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 SnChatRoom chatRoom;
final VoidCallback onSend;
@ -705,8 +706,26 @@ class _ChatInput extends StatelessWidget {
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
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final enterToSend = ref.watch(appSettingsProvider).enterToSend;
return Material(
elevation: 8,
color: Theme.of(context).colorScheme.surface,
@ -806,30 +825,42 @@ class _ChatInput extends StatelessWidget {
],
),
Expanded(
child: TextField(
controller: messageController,
decoration: InputDecoration(
hintText:
(chatRoom.type == 1 && chatRoom.name == null)
? 'chatDirectMessageHint'.tr(
args: [
chatRoom.members!
.map((e) => e.account.nick)
.join(', '),
],
)
: 'chatMessageHint'.tr(args: [chatRoom.name!]),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
child: RawKeyboardListener(
focusNode: FocusNode(),
onKey: (event) => _handleKeyPress(context, ref, event),
child: TextField(
controller: messageController,
inputFormatters: [
if (enterToSend)
TextInputFormatter.withFunction((oldValue, newValue) {
if (newValue.text.endsWith('\n')) {
return oldValue;
}
return newValue;
}),
],
decoration: InputDecoration(
hintText:
(chatRoom.type == 1 && chatRoom.name == null)
? 'chatDirectMessageHint'.tr(
args: [
chatRoom.members!
.map((e) => e.account.nick)
.join(', '),
],
)
: 'chatMessageHint'.tr(args: [chatRoom.name!]),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
),
maxLines: null,
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
maxLines: null,
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (_) => onSend(),
),
),
IconButton(

View File

@ -26,6 +26,7 @@ class SettingsScreen extends HookConsumerWidget {
final serverUrl = ref.watch(serverUrlProvider);
final prefs = ref.watch(sharedPreferencesProvider);
final controller = TextEditingController(text: serverUrl);
final settings = ref.watch(appSettingsProvider);
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);
},
),
),
],
),
),