This commit is contained in:
NIGGER SLAYER 2026-04-21 18:25:03 +08:00
parent 7e136f4d7d
commit c4e177dd7e
19 changed files with 1125 additions and 331 deletions

View File

@ -0,0 +1,335 @@
# Voice Room Emoji Package Plan
## Goal
This document only records a feasible implementation plan. No business code is changed in this round.
Requirement summary:
- Add an emoji package button next to the voice-room chat entry.
- The emoji package button and text chat must open the same bottom composer sheet.
- Emoji packages are shown only on mic seats.
- Emoji packages must not appear in room chat messages, including the `All`, `Chat`, and `Gift` tabs.
- Wait for final backend data and UI assets before starting the real implementation.
## Current Codebase Status
The current project already has several reusable pieces, so the feature is feasible and does not need a brand-new architecture.
Reusable entry points:
- Bottom area layout: `lib/ui_kit/widgets/room/room_bottom_widget.dart`
- Chat composer popup: `lib/ui_kit/widgets/room/room_msg_input.dart`
- Seat emoji display: `lib/modules/room/seat/sc_seat_item.dart`
- Room RTM send/receive: `lib/services/audio/rtm_manager.dart`
- Seat transient state: `lib/services/audio/rtc_manager.dart`
- Seat model: `lib/shared/business_logic/models/res/mic_res.dart`
- Emoji material API: `lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart`
- Emoji material model: `lib/shared/business_logic/models/res/sc_room_emoji_res.dart`
- Existing room message type: `lib/app/constants/sc_room_msg_type.dart`
Confirmed reusable behavior in current code:
- `SCRoomMsgType.emoticons` already exists.
- The room receiver already handles `EMOTICONS` separately and calls `RtcProvider.starPlayEmoji(msg)`.
- `SCSeatItem` already has an `Emoticons` widget with queueing, fade animation, and auto-dismiss.
- The project already has `/material/emoji/all`, and the response model already supports category + emoji list.
## Recommended Architecture
Recommended direction: keep one composer sheet and support two entry modes.
Recommended entry modes:
- `text` mode: opened from the existing chat entry, keyboard focused by default.
- `emojiPackage` mode: opened from the new emoji package button, keyboard hidden by default, emoji package panel open by default.
Recommended widget strategy:
- Reuse `RoomMsgInput` instead of creating a second bottom sheet.
- Add an input parameter such as `initialMode` or `initialPanel`.
- Keep the text input area as the same top row.
- Use the lower content area to switch between text helper emoji and room emoji package content.
Why this is the best fit:
- It matches the requirement that text chat and emoji package use the same bottom popup.
- It keeps interaction and style in one place.
- It avoids future drift between two different room composer implementations.
## Recommended Interaction Flow
### 1. Bottom bar
Add a new emoji package button next to the room chat entry in `RoomBottomWidget`.
Recommended behavior:
- Tap chat entry: open `RoomMsgInput(initialMode: text)`.
- Tap emoji package button: open `RoomMsgInput(initialMode: emojiPackage)`.
- If the current user is not on mic, the emoji package button can either be disabled or show a toast such as "Take a mic first".
Important layout note:
- `RoomBottomWidget` currently uses hard-coded width math for the bottom bar and for `_resolveGiftCenterX(...)`.
- After adding one more action button, the row layout and gift floating button center calculation both need to be updated together.
- If only the row is changed and `_resolveGiftCenterX(...)` is not updated, the floating gift button can become visually misaligned.
### 2. Composer sheet
Recommended sheet structure:
- Top area: existing input field, send button, and keyboard/emoji switch logic.
- Bottom area in `text` mode: current text emoji helper can remain for typing unicode emoji.
- Bottom area in `emojiPackage` mode: category tabs + emoji package grid from backend data.
Recommended send interaction for emoji package:
- Tapping an emoji package item sends immediately.
- The sheet should stay open after sending so the user can send multiple emoji packages continuously.
- If UI later wants tap-to-close, that can be a product choice, but continuous sending is the safer default for room interaction.
### 3. Seat-only display
Emoji package messages should not enter room chat lists.
Recommended rule:
- Text messages still follow the existing room chat path.
- Emoji package clicks trigger seat animation only.
- No new item should be inserted into `roomAllMsgList`, `roomChatMsgList`, or `roomGiftMsgList`.
## Recommended Send Path
### Recommended payload shape
The smallest-change plan is to reuse the existing room custom message structure:
```dart
Msg(
groupId: roomAccount,
type: SCRoomMsgType.emoticons,
msg: emoji.sourceUrl,
number: currentSeatIndex,
user: currentUserProfile,
role: currentUserRole,
)
```
Recommended field meaning:
- `type`: `EMOTICONS`
- `msg`: selected emoji resource URL
- `number`: current seat index
- `user`: sender profile, for consistency with existing room messages
### Why not send it as a normal chat message
Because `RtmProvider.addMsg(...)` always inserts the message into `roomAllMsgList` first.
Current receive side is already good enough:
- In `_newGroupMsg(...)`, `EMOTICONS` goes directly to `RtcProvider.starPlayEmoji(msg)` and then `return`.
- That means remote users will not see emoji package content in room chat.
Current sender side still needs care during implementation:
- If the sender uses `dispatchMessage(...)` with default `addLocal: true`, the local sender will still push one item into `roomAllMsgList`.
- That would violate the requirement that emoji packages must not show in chat.
Recommended implementation behavior later:
- Build the `Msg`.
- First do a local optimistic seat display by calling `RtcProvider.starPlayEmoji(msg)`.
- Then call `dispatchMessage(msg, addLocal: false)`.
This gives the sender instant seat feedback while keeping the chat list clean.
## Recommended Seat Resolution Rule
When sending an emoji package, determine the seat index at send time, not when the sheet is opened.
Recommended lookup:
- Use `RtcProvider.userOnMaiInIndex(currentUserId)` when the user taps an emoji item.
Reason:
- The user may switch mic seats after opening the popup.
- Using the latest seat index avoids showing the emoji on the wrong seat.
If the returned seat index is `-1`:
- Do not send.
- Show a toast telling the user that only users on mic can use emoji packages.
## Backend Dependency Assessment
### Material data
The project already has `/material/emoji/all` and the local model already supports:
- category name
- category cover
- category-level ownership state
- category-level amount
- emoji list
- emoji cover URL
- emoji source URL
This means the current backend structure is already close to the requirement shown in the design.
Backend items to confirm before development:
- Whether `/material/emoji/all` is the final interface for this feature
- Whether category ordering is controlled by backend
- Whether each category has a tab icon or cover ready for UI
- Whether `coverUrl` is the correct image for the grid cell
- Whether `sourceUrl` is the real image used on the mic seat
- Whether ownership is category-level only or item-level
If ownership later becomes item-level, the current model may need extension because it currently looks category-oriented.
### No extra send API is strictly required
For the minimal-change version, no new HTTP send API is needed.
Recommended transport:
- Use the existing room RTM custom message channel.
- Reuse `EMOTICONS` as the message type.
This keeps the feature aligned with the current room event architecture.
## Optional Sync Decision
There are two possible product definitions for emoji package state:
### Option A: transient RTM-only state
Behavior:
- Emoji package is only an instant seat effect.
- Users who join mid-animation do not need to recover the current emoji state.
Pros:
- Smallest frontend change
- No need for backend seat-state persistence
- Best fit for the current codebase
Recommended default:
- Yes. This should be the first implementation choice unless product explicitly wants state recovery.
### Option B: recoverable seat state
Behavior:
- If a user joins the room late or reconnects, they can still see the currently active emoji package on the seat during its valid duration.
If product wants this, backend and model changes are needed:
- mic list response needs to include active emoji state
- active emoji state should include at least resource URL and expiration timing
- frontend model parsing needs to be updated
Important current gap:
- `MicRes.fromJson(...)` does not parse `emojiPath`
- `MicRes.toJson()` also does not serialize `emojiPath`
So if the final backend plan depends on seat polling or room re-entry recovery, this model area will need a small follow-up patch during implementation.
## Current Code Caveats To Remember
### 1. `RoomMsgInput` is text-first today
Current behavior:
- It always autofocuses the text field.
- It only supports the local text emoji helper based on `SCEmojiDatas.smileys`.
Meaning for future implementation:
- It needs a mode parameter so opening from the emoji package button can skip keyboard autofocus and show the package panel first.
### 2. Seat emoji display currently uses an implicit field contract
Current behavior in `RtcProvider.starPlayEmoji(msg)`:
- It writes `emojiPath: msg.msg`
- It writes `type: msg.type`
- It writes `number: msg.msg`
Current behavior in `SCSeatItem.Emoticons`:
- For dice and RPS, `number` is treated like a result value
- For other types, `number` is treated like an image URL
Meaning:
- The current image emoji path works because the code uses a field-reuse convention.
- Later implementation should stay compatible with this path unless the seat overlay model is cleaned up in one pass.
### 3. Emoji cache utility exists but is not wired into the room sheet yet
`SCAppGeneralManager` already has:
- `emojiByTab`
- `emojiAll()`
But this is not currently connected to the room composer.
Recommended later choice:
- Either reuse `SCAppGeneralManager` for caching
- Or load emoji package data directly inside the room sheet and cache locally there
Either is feasible. Reusing the manager is better if the same materials are used in multiple room surfaces.
## Suggested Development Sequence Later
When backend and UI are ready, the development order should be:
1. Add the new emoji package button in `RoomBottomWidget` and adjust layout math together with `_resolveGiftCenterX(...)`.
2. Refactor `RoomMsgInput` into one shared room composer with `text` and `emojiPackage` entry modes.
3. Wire the sheet to load emoji categories and emoji items from `/material/emoji/all`.
4. Add on-mic gating and resolve seat index at click time.
5. On emoji package click, do local optimistic seat playback.
6. Send RTM `EMOTICONS` with `addLocal: false`.
7. Verify that `All`, `Chat`, and `Gift` tabs remain unchanged after sending.
8. If product wants reconnect recovery, then patch `MicRes` parsing and seat sync logic.
## Acceptance Checklist
- New emoji package button appears next to the room chat entry.
- Chat entry and emoji package button open the same bottom composer sheet.
- Opening from chat goes to text mode.
- Opening from emoji package goes to emoji package mode.
- Only users on mic can send emoji packages.
- Sender sees the emoji package on their own seat immediately.
- Other users see the emoji package on the sender's seat.
- Emoji package sending does not create new rows in `All`, `Chat`, or `Gift`.
- Existing text chat sending is not affected.
- Existing gift floating button remains visually centered after the new button is added.
- Repeated emoji package taps queue and play correctly on the seat.
## Final Recommendation
This feature is feasible with low-to-medium implementation risk because the project already has the key building blocks:
- shared room composer entry
- room RTM custom message channel
- `EMOTICONS` message type
- seat emoji playback widget
- emoji material API
Recommended final direction:
- Reuse the existing room composer popup
- Reuse `EMOTICONS` as the transport type
- Use seat-index-based RTM payload
- Do local optimistic seat playback
- Do not insert emoji package messages into any chat list
- Treat backend seat-state recovery as optional, not mandatory

View File

@ -502,7 +502,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = S9X2AJ2US9;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -511,7 +511,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.1;
MARKETING_VERSION = 1.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.org.yumiparty;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -693,7 +693,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerDebug.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = F33K8VUZ62;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -702,7 +702,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.1;
MARKETING_VERSION = 1.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.org.yumiparty;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -722,7 +722,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerRelease.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = F33K8VUZ62;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -731,7 +731,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.1;
MARKETING_VERSION = 1.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.org.yumiparty;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

View File

@ -21,6 +21,7 @@ import '../../../ui_kit/components/sc_debounce_widget.dart';
import '../../../ui_kit/components/sc_tts.dart';
import '../../../ui_kit/components/dialog/dialog_base.dart';
import '../../../ui_kit/components/text/sc_text.dart';
import '../../../ui_kit/widgets/id/sc_special_id_badge.dart';
import '../../../main.dart';
import '../../index/main_route.dart';
@ -153,10 +154,41 @@ class _RoomDetailPageState extends State<RoomDetailPage> {
SizedBox(height: 5.w),
Row(
children: [
text(
"ID:${ref.currenRoom?.roomProfile?.userProfile?.getID()}",
fontSize: 12.sp,
textColor: Colors.black,
SCSpecialIdBadge(
idText:
ref
.currenRoom
?.roomProfile
?.roomProfile
?.roomAccount ??
"",
showAnimated:
(ref
.currenRoom
?.roomProfile
?.roomProfile
?.roomAccount
?.isNotEmpty ??
false),
assetPath:
SCSpecialIdAssets.roomId,
animationWidth: 124.w,
animationHeight: 30.w,
textPadding: EdgeInsets.fromLTRB(
36.w,
7.w,
18.w,
7.w,
),
animationTextStyle: TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontWeight: FontWeight.w700,
),
normalTextStyle: TextStyle(
color: Colors.black,
fontSize: 12.sp,
),
),
SizedBox(width: 3.w),
GestureDetector(
@ -278,10 +310,44 @@ class _RoomDetailPageState extends State<RoomDetailPage> {
),
Row(
children: [
text(
"ID:${ref.currenRoom?.roomProfile?.userProfile?.account}",
textColor: Colors.black,
fontSize: 14.sp,
SCSpecialIdBadge(
idText:
ref
.currenRoom
?.roomProfile
?.userProfile
?.getID() ??
"",
showAnimated:
ref
.currenRoom
?.roomProfile
?.userProfile
?.hasSpecialId() ??
false,
assetPath:
SCSpecialIdAssets
.roomOwnerId,
animationWidth: 132.w,
animationHeight: 32.w,
textPadding:
EdgeInsets.fromLTRB(
40.w,
8.w,
18.w,
8.w,
),
animationTextStyle:
TextStyle(
color: Colors.white,
fontSize: 13.sp,
fontWeight:
FontWeight.w700,
),
normalTextStyle: TextStyle(
color: Colors.black,
fontSize: 14.sp,
),
),
SizedBox(width: 3.w),
GestureDetector(
@ -304,7 +370,7 @@ class _RoomDetailPageState extends State<RoomDetailPage> {
.currenRoom
?.roomProfile
?.userProfile
?.account ??
?.getID() ??
"",
),
);

View File

@ -12,6 +12,7 @@ import '../../../ui_kit/components/sc_compontent.dart';
import '../../../ui_kit/components/sc_page_list.dart';
import '../../../ui_kit/components/sc_tts.dart';
import '../../../ui_kit/components/text/sc_text.dart';
import '../../../ui_kit/widgets/id/sc_special_id_badge.dart';
import '../../../shared/tools/sc_lk_dialog_util.dart';
import '../../../ui_kit/widgets/room/room_user_info_card.dart';
@ -148,22 +149,26 @@ class _RoomOnlinePageState
GestureDetector(
child: Container(
padding: EdgeInsets.symmetric(vertical: 3.w),
child: Row(
textDirection: TextDirection.ltr,
children: [
text(
"ID:${userInfo.getID()}",
fontSize: 12.sp,
textColor: Colors.black,
fontWeight: FontWeight.bold,
),
SizedBox(width: 5.w),
Image.asset(
"sc_images/room/sc_icon_user_card_copy_id.png",
width: 12.w,
height: 12.w,
),
],
child: SCSpecialIdBadge(
idText: userInfo.getID(),
showAnimated: userInfo.hasSpecialId(),
assetPath: SCSpecialIdAssets.userId,
animationWidth: 110.w,
animationHeight: 28.w,
textPadding: EdgeInsets.fromLTRB(33.w, 6.w, 16.w, 6.w),
animationTextStyle: TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontWeight: FontWeight.bold,
),
normalTextStyle: TextStyle(
color: Colors.black,
fontSize: 12.sp,
fontWeight: FontWeight.bold,
),
showCopyIcon: true,
copyIconSize: 12.w,
copyIconSpacing: 5.w,
),
),
onTap: () {

View File

@ -28,6 +28,7 @@ class SCSeatItem extends StatefulWidget {
class _SCSeatItemState extends State<SCSeatItem> with TickerProviderStateMixin {
static const String _seatHeartbeatValueIconAsset =
"sc_images/room/sc_icon_room_seat_heartbeat_value.png";
static const double _seatHeaddressScaleMultiplier = 1.3;
RtcProvider? provider;
_SeatRenderSnapshot? _cachedSnapshot;
@ -56,6 +57,8 @@ class _SCSeatItemState extends State<SCSeatItem> with TickerProviderStateMixin {
window.locale.languageCode == "ar"
? ""
: seatSnapshot.headdressSourceUrl;
final double seatBaseSize = widget.isGameModel ? 40.w : 55.w;
final bool hasHeaddress = resolvedHeaddress.isNotEmpty;
return GestureDetector(
behavior: HitTestBehavior.opaque,
@ -66,9 +69,10 @@ class _SCSeatItemState extends State<SCSeatItem> with TickerProviderStateMixin {
children: [
SizedBox(
key: _targetKey,
width: widget.isGameModel ? 40.w : 55.w,
height: widget.isGameModel ? 40.w : 55.w,
width: seatBaseSize,
height: seatBaseSize,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
Sonic(
@ -77,10 +81,15 @@ class _SCSeatItemState extends State<SCSeatItem> with TickerProviderStateMixin {
isGameModel: widget.isGameModel,
),
seatSnapshot.hasUser
? head(
url: seatSnapshot.userAvatar,
width: widget.isGameModel ? 40.w : 55.w,
headdress: resolvedHeaddress,
? Transform.scale(
scale:
hasHeaddress ? _seatHeaddressScaleMultiplier : 1,
child: head(
url: seatSnapshot.userAvatar,
width: seatBaseSize,
height: seatBaseSize,
headdress: resolvedHeaddress,
),
)
: (seatSnapshot.micLock
? Image.asset(

View File

@ -14,6 +14,7 @@ import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
import 'package:yumi/ui_kit/widgets/id/sc_special_id_badge.dart';
class MePage2 extends StatefulWidget {
const MePage2({super.key});
@ -153,9 +154,19 @@ class _MePage2State extends State<MePage2> {
),
),
SizedBox(height: 2.w),
Text(
'ID:${profile?.account ?? ''}',
style: TextStyle(
SCSpecialIdBadge(
idText: profile?.getID() ?? '',
showAnimated: profile?.hasSpecialId() ?? false,
assetPath: SCSpecialIdAssets.userIdLarge,
animationWidth: 150.w,
animationHeight: 34.w,
textPadding: EdgeInsets.fromLTRB(48.w, 8.w, 18.w, 8.w),
animationTextStyle: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w700,
),
normalTextStyle: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w600,

View File

@ -34,6 +34,7 @@ import '../../../app/constants/sc_screen.dart';
import '../../../shared/business_logic/models/res/sc_user_counter_res.dart';
import '../../../shared/business_logic/models/res/sc_user_identity_res.dart';
import '../../../shared/business_logic/usecases/sc_fixed_width_tabIndicator.dart';
import '../../../ui_kit/widgets/id/sc_special_id_badge.dart';
import '../../chat/chat_route.dart';
class PersonDetailPage extends StatefulWidget {
@ -398,11 +399,23 @@ class _PersonDetailPageState extends State<PersonDetailPage>
Row(
children: [
SizedBox(width: 25.w),
text(
"ID:${ref.userProfile?.account ?? ""}",
textColor: Colors.white,
fontWeight: FontWeight.w400,
fontSize: 16.sp,
SCSpecialIdBadge(
idText: ref.userProfile?.getID() ?? "",
showAnimated: ref.userProfile?.hasSpecialId() ?? false,
assetPath: SCSpecialIdAssets.userIdLarge,
animationWidth: 150.w,
animationHeight: 34.w,
textPadding: EdgeInsets.fromLTRB(48.w, 8.w, 18.w, 8.w),
animationTextStyle: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w700,
),
normalTextStyle: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w400,
),
),
],
),

View File

@ -568,7 +568,7 @@ class SocialChatUserProfile {
///Id
String getID() {
String uid = account ?? "";
if (_ownSpecialId != null) {
if (hasSpecialId()) {
uid = _ownSpecialId?.account ?? "";
}
return uid;

View File

@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart';
class SCSpecialIdAssets {
static const String roomId = 'sc_images/general/room_id.svga';
static const String roomOwnerId = 'sc_images/general/room_id_custom.svga';
static const String userId = 'sc_images/general/user_id.svga';
static const String userIdLarge = 'sc_images/general/user_id_4.svga';
}
class SCSpecialIdBadge extends StatelessWidget {
const SCSpecialIdBadge({
super.key,
required this.idText,
this.showAnimated = false,
this.assetPath,
this.animationWidth = 120,
this.animationHeight = 28,
this.textPadding = EdgeInsets.zero,
this.animationTextStyle,
this.normalTextStyle,
this.normalPrefix = 'ID:',
this.showCopyIcon = false,
this.copyIconAssetPath = 'sc_images/room/sc_icon_user_card_copy_id.png',
this.copyIconSize = 12,
this.copyIconSpacing = 5,
this.copyIconColor,
this.loop = true,
this.active = true,
this.animationFit = BoxFit.fill,
});
final String idText;
final bool showAnimated;
final String? assetPath;
final double animationWidth;
final double animationHeight;
final EdgeInsetsGeometry textPadding;
final TextStyle? animationTextStyle;
final TextStyle? normalTextStyle;
final String normalPrefix;
final bool showCopyIcon;
final String copyIconAssetPath;
final double copyIconSize;
final double copyIconSpacing;
final Color? copyIconColor;
final bool loop;
final bool active;
final BoxFit animationFit;
@override
Widget build(BuildContext context) {
final normalizedId = idText.trim();
final shouldAnimate =
showAnimated &&
(assetPath?.isNotEmpty ?? false) &&
normalizedId.isNotEmpty;
final children = <Widget>[
shouldAnimate
? _buildAnimatedBadge(normalizedId)
: _buildPlainBadge(normalizedId),
];
if (showCopyIcon) {
children.add(SizedBox(width: copyIconSpacing));
children.add(
Image.asset(
copyIconAssetPath,
width: copyIconSize,
height: copyIconSize,
color: copyIconColor,
),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
textDirection: TextDirection.ltr,
children: children,
);
}
Widget _buildAnimatedBadge(String normalizedId) {
return SizedBox(
width: animationWidth,
height: animationHeight,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: SCSvgaAssetWidget(
assetPath: assetPath!,
width: animationWidth,
height: animationHeight,
active: active,
loop: loop,
fit: animationFit,
fallback: const SizedBox.shrink(),
),
),
Positioned.fill(
child: Padding(
padding: textPadding,
child: Align(
alignment: Alignment.centerLeft,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
normalizedId,
maxLines: 1,
overflow: TextOverflow.visible,
style:
animationTextStyle ??
const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
),
),
),
],
),
);
}
Widget _buildPlainBadge(String normalizedId) {
return Text(
'$normalPrefix$normalizedId',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
normalTextStyle ??
const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
);
}
}

View File

@ -256,6 +256,7 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
_ensureCenterVisual(request);
_trimQueuedRequestsOverflow();
_queue.add(_QueuedRoomGiftSeatFlightRequest(request: request));
_syncIdleCenterVisual();
_scheduleNextAnimation();
}
@ -328,17 +329,7 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
for (final queuedRequest in requestsToRemove) {
_queue.remove(queuedRequest);
}
if (!_isPlaying && _queue.isEmpty) {
if (!mounted) {
_centerRequest = null;
_centerImageProvider = null;
return;
}
setState(() {
_centerRequest = null;
_centerImageProvider = null;
});
}
_syncIdleCenterVisual();
}
int _countTrackedRequests(String queueTag) {
@ -406,6 +397,7 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
_scheduleNextAnimation();
});
} else {
_syncIdleCenterVisual();
_scheduleNextAnimation();
}
return;
@ -443,28 +435,21 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
}
void _finishCurrentAnimation() {
final hasPendingRequests = _queue.isNotEmpty;
if (!mounted) {
if (!hasPendingRequests) {
_centerRequest = null;
_centerImageProvider = null;
}
_activeRequest = null;
_activeImageProvider = null;
_activeTargetOffset = null;
_isPlaying = false;
_syncIdleCenterVisual();
return;
}
setState(() {
if (!hasPendingRequests) {
_centerRequest = null;
_centerImageProvider = null;
}
_activeRequest = null;
_activeImageProvider = null;
_activeTargetOffset = null;
_isPlaying = false;
});
_syncIdleCenterVisual();
_scheduleNextAnimation();
}
@ -472,7 +457,10 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
if (_centerRequest != null && _centerImageProvider != null) {
return;
}
final imageProvider = _resolveImageProvider(request.imagePath);
final imageProvider = _resolveImageProvider(
request.imagePath,
request: request,
);
if (!mounted) {
_centerRequest = request;
_centerImageProvider = imageProvider;
@ -484,6 +472,39 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
});
}
void _syncIdleCenterVisual() {
if (_isPlaying || _activeRequest != null) {
return;
}
final nextRequest = _queue.isNotEmpty ? _queue.first.request : null;
final nextImageProvider =
nextRequest == null
? null
: _resolveImageProvider(
nextRequest.imagePath,
request: nextRequest,
);
if (!mounted) {
_centerRequest = nextRequest;
_centerImageProvider = nextImageProvider;
return;
}
final shouldUpdate =
_centerRequest != nextRequest ||
_centerImageProvider != nextImageProvider;
if (!shouldUpdate) {
return;
}
setState(() {
_centerRequest = nextRequest;
_centerImageProvider = nextImageProvider;
});
}
Offset? _resolveTargetOffset(String targetUserId) {
final overlayContext = _overlayKey.currentContext;
final targetKey = widget.resolveTargetKey(targetUserId);
@ -507,8 +528,11 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
return overlayBox.globalToLocal(globalTargetCenter);
}
ImageProvider<Object> _resolveImageProvider(String imagePath) {
final currentRequest = _activeRequest ?? _centerRequest;
ImageProvider<Object> _resolveImageProvider(
String imagePath, {
RoomGiftSeatFlightRequest? request,
}) {
final currentRequest = request ?? _activeRequest ?? _centerRequest;
final logicalSize =
currentRequest == null
? null

View File

@ -3,6 +3,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/shared/business_logic/models/res/sc_broad_cast_luck_gift_push.dart';
import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart';
class LuckGiftNomorAnimWidget extends StatefulWidget {
@ -17,6 +18,20 @@ class LuckGiftNomorAnimWidget extends StatefulWidget {
}
class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
static const double _groupShiftXRatio = 26 / 422;
static const double _groupShiftYRatio = 18 / 422;
static const double _groupShiftYExtraUnits = 40;
static const double _avatarScale = 0.8;
static const double _avatarExtraShiftXUnits = -2;
static const double _avatarExtraShiftYUnits = 18;
static const double _avatarDiameterRatio = 76 / 422;
static const double _avatarLeftRatio = 152 / 422;
static const double _avatarTopRatio = 64 / 422;
static const double _awardTopRatio = 153 / 422;
static const double _awardXOffsetRatio = -13 / 422;
static const double _multipleTopRatio = 193 / 422;
static const double _multipleXOffsetRatio = -22 / 422;
String? _currentBurstEventId;
bool _isRewardAmountVisible = false;
@ -34,6 +49,48 @@ class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
return awardAmount.toString();
}
String _formatMultiple(num multiple) {
if (multiple % 1 == 0) {
return multiple.toInt().toString();
}
return multiple.toString();
}
Widget _buildSenderAvatar({
required String avatarUrl,
required double outerSize,
}) {
final avatarPadding = outerSize * 0.045;
final innerSize = outerSize - avatarPadding * 2;
return RepaintBoundary(
child: Container(
width: outerSize,
height: outerSize,
padding: EdgeInsets.all(avatarPadding),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFFFFD680),
boxShadow: const [
BoxShadow(
color: Color(0x55243A74),
blurRadius: 16,
offset: Offset(0, 6),
),
],
),
child: ClipOval(
child: netImage(
url: avatarUrl,
width: innerSize,
height: innerSize,
fit: BoxFit.cover,
shape: BoxShape.circle,
),
),
),
);
}
@override
Widget build(BuildContext context) {
return IgnorePointer(
@ -45,6 +102,28 @@ class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
_isRewardAmountVisible = false;
return const SizedBox.shrink();
}
final screenWidth = ScreenUtil().screenWidth;
final groupShiftX = screenWidth * _groupShiftXRatio;
final groupShiftY =
screenWidth * _groupShiftYRatio + _groupShiftYExtraUnits.w;
final avatarBaseDiameter = screenWidth * _avatarDiameterRatio;
final avatarDiameter = avatarBaseDiameter * _avatarScale;
final avatarInset = (avatarBaseDiameter - avatarDiameter) / 2;
final avatarLeft =
screenWidth * _avatarLeftRatio +
groupShiftX +
avatarInset +
_avatarExtraShiftXUnits.w;
final avatarTop =
screenWidth * _avatarTopRatio +
groupShiftY +
avatarInset +
_avatarExtraShiftYUnits.w;
final awardTop = screenWidth * _awardTopRatio + groupShiftY;
final awardXOffset = screenWidth * _awardXOffsetRatio + groupShiftX;
final multipleTop = screenWidth * _multipleTopRatio + groupShiftY;
final multipleXOffset =
screenWidth * _multipleXOffsetRatio + groupShiftX;
final rewardAnimationKey = ValueKey<String>(
'${rewardData.sendUserId ?? ""}'
'|${rewardData.acceptUserId ?? ""}'
@ -93,37 +172,83 @@ class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
},
),
),
if (_isRewardAmountVisible &&
(rewardData.userAvatar ?? '').trim().isNotEmpty)
Positioned(
left: avatarLeft,
top: avatarTop,
child: _buildSenderAvatar(
avatarUrl: (rewardData.userAvatar ?? '').trim(),
outerSize: avatarDiameter,
),
),
if (_isRewardAmountVisible)
Positioned.fill(
child: Align(
alignment: const Alignment(0, 0.12),
child: Transform.translate(
offset: Offset(5.w, -4.w),
Positioned(
top: awardTop,
left: 0,
right: 0,
child: Transform.translate(
offset: Offset(awardXOffset, 0),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: ScreenUtil().screenWidth * 0.56,
maxWidth: screenWidth * 0.34,
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
padding: EdgeInsets.only(left: 0.w, right: 0.w),
child: Text(
_formatAwardAmount(rewardData.awardAmount ?? 0),
maxLines: 1,
style: TextStyle(
fontSize: 28.sp,
color: const Color(0xFFFFF3B6),
fontWeight: FontWeight.w900,
fontStyle: FontStyle.italic,
height: 1,
shadows: const [
Shadow(
color: Color(0xCC7A3E00),
blurRadius: 12,
offset: Offset(0, 3),
),
],
),
child: Text(
_formatAwardAmount(rewardData.awardAmount ?? 0),
maxLines: 1,
style: TextStyle(
fontSize: 28.sp,
color: const Color(0xFFFFF3B6),
fontWeight: FontWeight.w900,
fontStyle: FontStyle.italic,
height: 1,
shadows: const [
Shadow(
color: Color(0xCC7A3E00),
blurRadius: 12,
offset: Offset(0, 3),
),
],
),
),
),
),
),
),
),
if (_isRewardAmountVisible && (rewardData.multiple ?? 0) > 0)
Positioned(
top: multipleTop,
left: 0,
right: 0,
child: Transform.translate(
offset: Offset(multipleXOffset, 0),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: screenWidth * 0.18,
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'x${_formatMultiple(rewardData.multiple ?? 0)}',
maxLines: 1,
style: TextStyle(
fontSize: 17.sp,
color: const Color(0xFFF4F6FF),
fontWeight: FontWeight.w800,
fontStyle: FontStyle.italic,
height: 1,
shadows: const [
Shadow(
color: Color(0x990B2D72),
blurRadius: 10,
offset: Offset(0, 2),
),
],
),
),
),

View File

@ -26,6 +26,7 @@ import 'package:yumi/services/general/sc_app_general_manager.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/services/auth/user_profile_manager.dart';
import 'package:yumi/modules/gift/gift_page.dart';
import 'package:yumi/ui_kit/widgets/id/sc_special_id_badge.dart';
import '../../../shared/data_sources/models/enum/sc_room_roles_type.dart';
import '../../../shared/business_logic/models/res/sc_user_identity_res.dart';
@ -265,11 +266,40 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
],
),
SizedBox(height: 2.w),
text(
"ID:${ref.userCardInfo?.userProfile?.getID() ?? 0}",
fontSize: 12.sp,
textColor: Colors.black,
fontWeight: FontWeight.bold,
SCSpecialIdBadge(
idText:
ref
.userCardInfo
?.userProfile
?.getID() ??
"",
showAnimated:
ref
.userCardInfo
?.userProfile
?.hasSpecialId() ??
false,
assetPath:
SCSpecialIdAssets.userId,
animationWidth: 110.w,
animationHeight: 28.w,
textPadding:
EdgeInsets.fromLTRB(
33.w,
6.w,
16.w,
6.w,
),
animationTextStyle: TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontWeight: FontWeight.bold,
),
normalTextStyle: TextStyle(
color: Colors.black,
fontSize: 12.sp,
fontWeight: FontWeight.bold,
),
),
],
),

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.1.1+3
version: 1.2.0+4
environment:

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -3,6 +3,10 @@
## 当前总目标
- 控制当前 Flutter Android 发包体积,持续定位冗余组件、超大资源和不合理构建配置,并把每一步处理结果落盘记录。
## 本轮靓号动效替换(已完成)
- 已按 2026-04-21 最新靓号动效需求,把桌面“靓号动效”里的 `room_id / room_id_custom / user_id / user_id_4` 四套 `SVGA` 素材导入 Flutter 工程,并新增统一的靓号 ID 展示组件;当前会按 `ownSpecialId != null && expiredTime > now` 判定是否仍为有效靓号,过期靓号会自动回退到原始账号展示。
- 已完成 4 个指定场景的替换:`room_id` 现用于房间详情里的房间号,`room_id_custom` 现用于房间详情里的房主 ID`user_id` 现用于房间在线列表和房间个人卡片,`user_id_4` 现用于个人主页和 `Me` 页;对应文案在命中靓号时会展示靓号号码,未命中时保持原始 `ID:` 文本样式。
## 本轮房间动效优化(进行中)
- 已按 2026-04-21 当前房间卡顿优化方案先落第一步“统一调度层”:新增房间特效调度器,在全屏 `VAP/SVGA` 高成本特效播放或排队期间,暂缓低优先级的房间进场动画、全局飘屏和座位飞屏入队,待高成本特效清空后按小间隔续播,先减少多条动画链路同一时刻抢主线程的情况。
- 本步只处理“调度时机”,没有改动现有动画内部逻辑:礼物/飞屏/飘屏的现有倍率触发规则、现有上限截断、`customAnimationCount` 现有循环方式和当前视觉表现均保持原样,避免第一步就引入联动回归。
@ -68,6 +72,8 @@
- 已继续绕开幸运礼物紫色横幅里可疑的小图 `ExtendedImage` 渲染链:当前礼物图标已改为在 `initState` 里先构建 `buildCachedImageProvider`,再通过基础 `Image(image: provider)` 直接绘制,并额外输出一次 `gift icon frame ready` 日志,用于确认这张图是否真的完成首帧解码显示;这样可以把问题进一步收敛为“图片 provider/解码”还是“组件布局/视觉观感”。
- 已按 2026-04-21 最新联调请求,把紫色幸运礼物横幅 `from` 后的图标临时替换为金币图标:这一步只用于快速验证该图标位本身的布局和可见性是否正常,不再受当前礼物图资源链路影响;如果金币图标能稳定显示,就说明问题仍集中在礼物图渲染链,而不是这个位置被遮挡或根本没画出来。
- 已按 2026-04-21 最新回归把 [floating_luck_gift_screen_widget] 的样式撤回到最初实现,不再继续在幸运礼物飘屏横幅上做偏题调试;同时已定位到真正缺图的是消息栏里的 `gameLuckyGift_5` 高亮消息,这条消息此前只写入了 `awardAmount/user/msg`,没有把 `gift` 一并塞进去,导致 `room_msg_item.dart` 读取 `widget.msg.gift?.giftPhoto` 时天然为空。当前已在 `RTM` 构造高亮 lucky 消息时补回 `giftPhoto`,让消息栏里的紫色 lucky message 能和其它消息一样拿到礼物图。
- 已按 2026-04-21 最新特效需求继续调整幸运礼物 `burst`:在 `luck_gift_reward_burst.svga` 下方那块淡色圆角矩形区域,补上了与 `SVGA` 同步进场/消失的中奖倍数文案;当前会和中央金额文本共用同一显隐时机,并在 `multiple > 0` 时展示为 `xN`,避免出现 `x0` 这类无效信息。
- 已继续按 2026-04-21 最新特效稿给幸运礼物 `burst` 补上中奖发送者头像:当前头像会出现在你标的黑块位置附近,并和 `SVGA` 使用同一套开始/结束显隐时机同步出现与消失;头像显示只读取当前中奖事件的 `userAvatar`,不改现有 `burst` 触发条件和播放时长。
- 已优化语言房麦位/头像的二次确认交互:普通用户点击可上麦的空麦位时,当前会直接执行上麦,不再先弹出只有 `Take the mic / Cancel` 的确认层;普通用户点击房间头像或已占麦位上的用户头像时,也会直接打开个人卡片,不再额外弹出仅含 `Open user profile card / Cancel` 的底部确认。房主/管理员仍保留原有带禁麦、锁麦、邀请上麦等管理动作的底部菜单,避免误删管理能力。
- 已继续收窄语言房个人卡片前的“确认意义”弹层:当前用户在麦位上点击自己的头像时,也会直接打开自己的个人卡片,不再先弹出仅包含 `Leave the mic / Open user profile card / Cancel` 的底部菜单;同时个人卡片内的“离开麦位”入口已替换为新的 `leave` 视觉素材,和最新房间交互稿保持一致。
- 已继续微调语言房个人卡片与送礼 UI个人卡片底部动作文案现已支持两行居中展示避免 `Leave the mic` 这类英文按钮被硬截断;房间底部礼物入口也已切换为新的本地 `SVGA` 资源 `room_bottom_gift_button.svga`,保持房间底栏视觉和最新动效稿一致。
@ -239,6 +245,12 @@
- `lib/ui_kit/widgets/room/room_game_bottom_sheet.dart`
- `lib/ui_kit/widgets/svga/sc_svga_asset_widget.dart`
- `lib/modules/room/detail/room_detail_page.dart`
- `lib/modules/room/online/room_online_page.dart`
- `lib/modules/user/me_page2.dart`
- `lib/modules/user/profile/person_detail_page.dart`
- `lib/shared/business_logic/models/res/login_res.dart`
- `lib/ui_kit/widgets/id/sc_special_id_badge.dart`
- `lib/ui_kit/widgets/room/room_user_info_card.dart`
- `lib/modules/home/popular/party/sc_home_party_page.dart`
- `lib/modules/home/popular/mine/sc_home_mine_skeleton.dart`
- `lib/modules/home/popular/follow/sc_room_follow_page.dart`
@ -259,6 +271,10 @@
- `lib/modules/user/my_items/theme/bags_theme_page.dart`
- `lib/ui_kit/widgets/store/store_bag_page_helpers.dart`
- `sc_images/general/sc_no_data.png`
- `sc_images/general/room_id.svga`
- `sc_images/general/room_id_custom.svga`
- `sc_images/general/user_id.svga`
- `sc_images/general/user_id_4.svga`
- `sc_images/splash/sc_weekly_star_bg.png`
- `sc_images/splash/sc_icon_weekly_star_rank_1.png`
- `sc_images/splash/sc_icon_weekly_star_rank_2.png`
@ -419,6 +435,13 @@
- Launch image 仍是默认占位资源,提交前建议替换
- `android/key.properties` 保存了本地 upload keystore 口令,必须自行妥善备份,且不要提交到仓库
- 由于已显式移除 Agora 本地屏幕共享相关组件,如果业务后续要启用“屏幕共享/录屏推流”,需要再单独恢复相关 manifest 声明
- `burst` 特效叠加层已停止用 `Align + translate` 猜偏移,改为按 `luck_gift_reward_burst.svga` 拆出的参考图层比例重排头像、中奖金额、倍数,重点对齐头像黑色占位圈和底部倍数圆角块
- 根据最新真机截图,`burst` 叠加层已整体继续向右下微调,先修正“整体偏左上”的问题,不改头像和文案尺寸
- 按最新口头要求,`burst` 叠加层又整体额外向下平移 `40` 个设计单位,继续保持头像、金额、倍数三者相对位置不变
- `burst` 头像已按最新要求缩到原来的 `80%`,并同步补偿左上坐标,保持头像中心点仍然对准原坑位
- `burst` 头像继续按最新口头要求单独微调:向下 `10` 个设计单位、向左 `2` 个设计单位,其余金额和倍数位置不动
- `burst` 头像又继续单独向下补移 `8` 个设计单位,当前累计为向下 `18`、向左 `2`
- 飞向麦位动画补了统一的“中心图空闲同步”逻辑:目标坐标重试失败放弃、队列被清空、上一段飞行结束后,都会把中心静态礼物图切到队列头或直接清空,避免残留卡死在屏幕中央
## 下一步要做什么
- 将新的 `AAB` 上传到 Play Console并在首发流程中继续使用 Google 管理的 app signing key。