yumi-flutter/lib/ui_kit/widgets/room/room_msg_input.dart
2026-06-02 19:57:41 +08:00

555 lines
17 KiB
Dart

import 'package:extended_text_field/extended_text_field.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/app/config/business_logic_strategy.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/app/constants/sc_room_msg_type.dart';
import 'package:yumi/app/constants/sc_screen.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/services/general/sc_app_general_manager.dart';
import 'package:yumi/shared/business_logic/models/res/sc_room_emoji_res.dart';
import 'package:yumi/shared/business_logic/usecases/sc_case.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/tools/sc_keybord_util.dart';
import 'package:yumi/ui_kit/widgets/room/room_emoji_asset_image.dart';
import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart';
///聊天输入框
class RoomMsgInput extends StatefulWidget {
final String? atTextContent;
final bool initialShowEmoji;
const RoomMsgInput({
super.key,
this.atTextContent,
this.initialShowEmoji = false,
});
@override
State<RoomMsgInput> createState() => _RoomMsgInputState();
}
class _RoomMsgInputState extends State<RoomMsgInput> {
bool showSend = false;
bool showEmoji = false;
int _selectedEmojiCategoryIndex = 0;
String? _lastEmojiPanelLogSignature;
final FocusNode msgNode = FocusNode();
final TextEditingController controller = TextEditingController();
BusinessLogicStrategy get _strategy => SCGlobalConfig.businessLogicStrategy;
Color get _panelBackgroundColor => Colors.white24;
@override
void initState() {
super.initState();
showEmoji = widget.initialShowEmoji;
if (widget.atTextContent != null) {
controller.value = TextEditingValue(text: widget.atTextContent ?? "");
showSend = controller.text.trim().isNotEmpty;
}
}
@override
void dispose() {
controller.dispose();
msgNode.dispose();
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (showEmoji) {
_loadRoomEmojisIfNeeded();
}
}
@override
Widget build(BuildContext context) {
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
return Scaffold(
backgroundColor: Colors.transparent,
resizeToAvoidBottomInset: false,
body: Column(
children: <Widget>[
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
Navigator.pop(context);
},
),
),
AnimatedPadding(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
padding: EdgeInsets.only(bottom: keyboardHeight),
child: _buildBottomPanel(context),
),
],
),
);
}
Widget _buildBottomPanel(BuildContext context) {
return Container(
color: Colors.black,
child: SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(25.w, 12.w, 25.w, 12.w),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _toggleEmojiPanel,
child: SizedBox(
width: 24.w,
height: 24.w,
child: Image.asset(
showEmoji
? _strategy.getSCMessageChatPageChatKeyboardIcon()
: "sc_images/room/sc_icon_room_bottom_emoji.png",
color: showEmoji ? Colors.white : null,
fit: BoxFit.contain,
),
),
),
SizedBox(width: 15.w),
Expanded(child: _buildInputBar(context)),
],
),
if (showEmoji) SizedBox(height: 12.w),
if (showEmoji) _buildEmojiPanel(),
],
),
),
),
);
}
Widget _buildInputBar(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: _panelBackgroundColor,
borderRadius: BorderRadius.circular(height(5)),
),
child: Row(
children: [
Expanded(
child: ExtendedTextField(
controller: controller,
textDirection:
widget.atTextContent != null ? TextDirection.ltr : null,
specialTextSpanBuilder: AtTextSpanBuilder(),
focusNode: msgNode,
autofocus: !widget.initialShowEmoji,
textInputAction: TextInputAction.send,
onTap: () {
if (showEmoji) {
setState(() {
showEmoji = false;
});
}
},
onSubmitted: (_) {
_sendMessage();
},
onChanged: (s) {
setState(() {
showSend = controller.text.trim().isNotEmpty;
});
},
decoration: InputDecoration(
hintText: SCAppLocalizations.of(context)!.pleaseChatFfriendly,
fillColor: Colors.transparent,
hintStyle: TextStyle(color: Colors.white60, fontSize: sp(14)),
contentPadding: EdgeInsets.symmetric(horizontal: 15.w),
counterText: '',
isDense: true,
filled: false,
focusColor: Colors.transparent,
hoverColor: Colors.transparent,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
),
style: TextStyle(
fontSize: ScreenUtil().setSp(14),
color: Colors.white,
),
),
),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _sendMessage,
child: Opacity(
opacity: showSend ? 1 : 0.5,
child: Container(
padding: EdgeInsets.all(4.w),
width: 30.w,
height: 30.w,
child: Image.asset(
"sc_images/room/sc_icon_room_message_send.png",
),
),
),
),
SizedBox(width: 3.w),
],
),
);
}
Widget _buildEmojiPanel() {
final categories = _resolveRoomEmojiCategories(context);
_logEmojiPanelState(categories);
if (categories.isEmpty) {
return Container(
height: 220.w,
decoration: BoxDecoration(
color: _panelBackgroundColor,
borderRadius: BorderRadius.circular(height(5)),
),
);
}
final selectedIndex = _clampedEmojiCategoryIndex(categories);
final selectedCategory = categories[selectedIndex];
return Container(
height: 220.w,
decoration: BoxDecoration(
color: _panelBackgroundColor,
borderRadius: BorderRadius.circular(height(5)),
),
child: Column(
children: [
Expanded(
child: GridView.builder(
key: ValueKey(selectedCategory.id),
padding: EdgeInsets.fromLTRB(18.w, 14.w, 18.w, 10.w),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 18.w,
mainAxisSpacing: 12.w,
),
itemCount: selectedCategory.items.length,
itemBuilder: (context, index) {
final emojiItem = selectedCategory.items[index];
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
_sendRoomEmoji(emojiItem.sendResource);
},
child: Center(
child: RoomEmojiAssetImage(
key: ValueKey(emojiItem.displayResource),
asset: emojiItem.displayResource,
width: 42.w,
height: 42.w,
),
),
);
},
),
),
_buildEmojiCategoryBar(categories, selectedIndex),
],
),
);
}
Widget _buildEmojiCategoryBar(
List<_RoomEmojiCategory> categories,
int selectedIndex,
) {
return Container(
height: 50.w,
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 7.w),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.08),
border: Border(
top: BorderSide(color: Colors.white.withValues(alpha: 0.08)),
),
),
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: categories.length,
separatorBuilder: (_, __) => SizedBox(width: 10.w),
itemBuilder: (context, index) {
final category = categories[index];
final selected = index == selectedIndex;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (selected) {
return;
}
setState(() {
_selectedEmojiCategoryIndex = index;
});
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
width: 64.w,
height: 36.w,
decoration: BoxDecoration(
color:
selected
? Colors.white.withValues(alpha: 0.22)
: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(18.w),
border: Border.all(
color:
selected
? Colors.white.withValues(alpha: 0.30)
: Colors.transparent,
width: 1.w,
),
),
child: Center(
child: RoomEmojiAssetImage(
key: ValueKey("emoji_category_${category.id}"),
asset: category.iconResource,
width: 26.w,
height: 26.w,
),
),
),
);
},
),
);
}
void _toggleEmojiPanel() {
if (showEmoji) {
setState(() {
showEmoji = false;
});
FocusScope.of(context).requestFocus(msgNode);
return;
}
SCKeybordUtil.hide(context);
_roomEmojiInputDebugLog('toggle open: request remote emoji load');
_loadRoomEmojisIfNeeded(forceWhenEmpty: true);
setState(() {
showEmoji = true;
});
}
void _loadRoomEmojisIfNeeded({bool forceWhenEmpty = false}) {
final manager = Provider.of<SCAppGeneralManager>(context, listen: false);
_roomEmojiInputDebugLog(
'load if needed loaded=${manager.hasRoomEmojisLoaded} '
'loading=${manager.isFetchingRoomEmojis} '
'groups=${manager.roomEmojiGroups.length} '
'sourceAll=${manager.roomEmojisLoadedFromAll}',
);
final forceRefresh =
forceWhenEmpty &&
manager.hasRoomEmojisLoaded &&
manager.roomEmojiGroups.isEmpty;
manager.emojiAll(forceRefresh: forceRefresh);
}
List<_RoomEmojiCategory> _resolveRoomEmojiCategories(BuildContext context) {
final generalManager = Provider.of<SCAppGeneralManager>(context);
return generalManager.roomEmojiGroups
.map(_RoomEmojiCategory.fromRemote)
.where((category) => category.items.isNotEmpty)
.toList();
}
void _logEmojiPanelState(List<_RoomEmojiCategory> categories) {
final manager = Provider.of<SCAppGeneralManager>(context, listen: false);
final signature =
'show=$showEmoji loaded=${manager.hasRoomEmojisLoaded} '
'loading=${manager.isFetchingRoomEmojis} '
'sourceAll=${manager.roomEmojisLoadedFromAll} '
'rawGroups=${manager.roomEmojiGroups.length} '
'renderGroups=${categories.length} '
'renderEmojiTotal=${_countRenderedEmojiItems(categories)}';
if (_lastEmojiPanelLogSignature == signature) {
return;
}
_lastEmojiPanelLogSignature = signature;
_roomEmojiInputDebugLog(
'panel state $signature sample=${_renderedEmojiCategorySample(categories)}',
);
}
int _clampedEmojiCategoryIndex(List<_RoomEmojiCategory> categories) {
if (_selectedEmojiCategoryIndex < categories.length) {
return _selectedEmojiCategoryIndex;
}
return categories.isEmpty ? 0 : categories.length - 1;
}
void _sendRoomEmoji(String emojiAsset) {
final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
final currenRoom = rtcProvider.currenRoom;
final currentUser = AccountStorage().getCurrentUser()?.userProfile;
if (currenRoom == null || currentUser == null) {
return;
}
final seatIndex = rtcProvider.userOnMaiInIndex(currentUser.id ?? "");
final msg = Msg(
groupId: currenRoom.roomProfile?.roomProfile?.roomAccount ?? "",
role: rtcProvider.currenRoom?.entrants?.roles ?? "",
msg: emojiAsset,
type: SCRoomMsgType.emoticons,
user: currentUser,
number: seatIndex,
);
if (seatIndex > -1) {
rtcProvider.starPlayEmoji(msg);
}
Provider.of<RtmProvider>(
context,
listen: false,
).dispatchMessage(msg, addLocal: seatIndex < 0);
}
void _sendMessage() {
if (controller.text.trim().isEmpty) {
return;
}
final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
final currenRoom = rtcProvider.currenRoom;
if (currenRoom == null) {
return;
}
Provider.of<RtmProvider>(context, listen: false).dispatchMessage(
Msg(
groupId: currenRoom.roomProfile?.roomProfile?.roomAccount ?? "",
role: rtcProvider.currenRoom?.entrants?.roles ?? "",
msg: controller.text,
type: SCRoomMsgType.text,
user: AccountStorage().getCurrentUser()?.userProfile,
),
);
Navigator.pop(context);
}
}
class _RoomEmojiCategory {
const _RoomEmojiCategory({
required this.id,
required this.iconResource,
required this.items,
});
factory _RoomEmojiCategory.fromRemote(SCRoomEmojiRes group) {
final items =
(group.emojis ?? const <Emojis>[])
.map(_RoomEmojiItem.fromRemote)
.where((item) => item.sendResource.isNotEmpty)
.toList();
final firstPreview =
items.isNotEmpty ? items.first.displayResource.trim() : "";
final cover = group.cover?.trim() ?? "";
final id =
(group.id ?? group.groupCode ?? group.groupName ?? firstPreview).trim();
return _RoomEmojiCategory(
id: id.isEmpty ? firstPreview : id,
iconResource: cover.isNotEmpty ? cover : firstPreview,
items: items,
);
}
final String id;
final String iconResource;
final List<_RoomEmojiItem> items;
}
class _RoomEmojiItem {
const _RoomEmojiItem({
required this.displayResource,
required this.sendResource,
});
factory _RoomEmojiItem.fromRemote(Emojis emoji) {
final display = emoji.previewUrl.trim();
final send = emoji.sendUrl.trim();
return _RoomEmojiItem(
displayResource: display.isNotEmpty ? display : send,
sendResource: send.isNotEmpty ? send : display,
);
}
final String displayResource;
final String sendResource;
}
void _roomEmojiInputDebugLog(String message) {
debugPrint('[RoomEmoji][input] $message');
}
int _countRenderedEmojiItems(List<_RoomEmojiCategory> categories) {
return categories.fold<int>(
0,
(total, category) => total + category.items.length,
);
}
String _renderedEmojiCategorySample(List<_RoomEmojiCategory> categories) {
if (categories.isEmpty) {
return '[]';
}
return categories
.take(3)
.map((category) {
final first = category.items.isNotEmpty ? category.items.first : null;
return '{id=${category.id}, icon=${category.iconResource}, '
'count=${category.items.length}, firstDisplay=${first?.displayResource}, '
'firstSend=${first?.sendResource}}';
})
.join(', ');
}
class PopRoute extends PopupRoute {
final Duration _duration = Duration(milliseconds: 350);
Widget child;
PopRoute({required this.child});
@override
Color? get barrierColor => null;
@override
bool get barrierDismissible => true;
@override
String? get barrierLabel => null;
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return child;
}
@override
Duration get transitionDuration => _duration;
}