diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 7c90156..cb86d57 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -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" } diff --git a/lib/pods/chat_summary.g.dart b/lib/pods/chat_summary.g.dart index c582148..faba30b 100644 --- a/lib/pods/chat_summary.g.dart +++ b/lib/pods/chat_summary.g.dart @@ -6,7 +6,7 @@ part of 'chat_summary.dart'; // RiverpodGenerator // ************************************************************************** -String _$chatSummaryHash() => r'fa48d381f489f90055fb728f7e0fda6f8ef49d15'; +String _$chatSummaryHash() => r'19aad48b5fabb33a76b742400d3b738ceb81c40c'; /// See also [ChatSummary]. @ProviderFor(ChatSummary) diff --git a/lib/pods/config.dart b/lib/pods/config.dart index 9751a45..59a3969 100644 --- a/lib/pods/config.dart +++ b/lib/pods/config.dart @@ -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 kImageQualityLevel = { 'settingsImageQualityLowest': FilterQuality.none, @@ -46,40 +50,17 @@ final serverUrlProvider = Provider((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 { @@ -94,6 +75,7 @@ class AppSettingsNotifier extends StateNotifier { 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 { prefs.setBool(kAppAprilFoolFeatures, value); state = state.copyWith(aprilFoolFeatures: value); } + + void setEnterToSend(bool value) { + prefs.setBool(kAppEnterToSend, value); + state = state.copyWith(enterToSend: value); + } } final appSettingsProvider = diff --git a/lib/pods/config.freezed.dart b/lib/pods/config.freezed.dart new file mode 100644 index 0000000..0cd24bb --- /dev/null +++ b/lib/pods/config.freezed.dart @@ -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 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 get copyWith => _$AppSettingsCopyWithImpl(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 diff --git a/lib/screens/chat/chat.dart b/lib/screens/chat/chat.dart index aa44cba..572637d 100644 --- a/lib/screens/chat/chat.dart +++ b/lib/screens/chat/chat.dart @@ -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); diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index d685fef..5fc0fc6 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -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( diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 6b55517..50ca482 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -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(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); + }, + ), + ), ], ), ),