表情包 svga特效同步 语音房bug修复 VIP权益说明补充 背景 邀请码等

This commit is contained in:
roxy 2026-04-24 18:51:11 +08:00
parent fb86a9438c
commit 6db3aa5ae8
106 changed files with 4453 additions and 1713 deletions

View File

@ -603,6 +603,8 @@
"custom": "مخصص",
"customBackground": "خلفية مخصصة",
"example": "مثال",
"endPreview": "إنهاء المعاينة",
"selectAgain": "اختر مرة أخرى",
"store": "المتجر",
"viewFrame": "عرض الإطار",
"headdress": "إطارات",

View File

@ -620,6 +620,8 @@
"custom": "কাস্টম",
"customBackground": "কাস্টম ব্যাকগ্রাউন্ড",
"example": "উদাহরণ",
"endPreview": "প্রিভিউ শেষ করুন",
"selectAgain": "আবার নির্বাচন করুন",
"myItems": "আমার আইটেম",
"use": "ব্যবহার করুন",
"unUse": "ব্যবহার না করুন",

View File

@ -620,6 +620,8 @@
"custom": "Custom",
"customBackground": "Custom Background",
"example": "Example",
"endPreview": "End Preview",
"selectAgain": "Select again",
"myItems": "My items",
"use": "Use",
"unUse": "Unequip",

View File

@ -620,7 +620,9 @@
"custom": "Özel",
"customBackground": "Özel Arka Plan",
"example": "Örnek",
"myItems": "Eşyalarım",
"endPreview": "Önizlemeyi Bitir",
"selectAgain": "Tekrar seç",
"myItems": "Eşyalarım",
"use": "Kullan",
"unUse": "Kullanmama",
"renewal": "Yenileme",

View File

@ -7,6 +7,7 @@ import 'package:yumi/modules/chat/chat_route.dart';
import 'package:yumi/modules/store/store_route.dart';
import 'package:yumi/modules/index/index_page.dart';
import 'package:yumi/modules/user/settings/settings_route.dart';
import 'package:yumi/modules/user/vip/vip_route.dart';
import 'package:yumi/modules/wallet/wallet_route.dart';
import 'package:yumi/modules/auth/login_route.dart';
import 'package:yumi/modules/room/voice_room_route.dart';
@ -15,17 +16,25 @@ import 'package:yumi/app/routes/sc_404_page.dart';
class SCRoutes {
static String home = '/';
static List<SCIRouterProvider> _listRouter = [];
static final List<SCIRouterProvider> _listRouter = [];
static void configureRoutes(fluro.FluroRouter router) {
///
router.notFoundHandler = fluro.Handler(handlerFunc: (BuildContext? context, Map<String, List<String>> params) {
debugPrint('未找到目标页');
return SCWidgetNotFound();
});
router.notFoundHandler = fluro.Handler(
handlerFunc: (BuildContext? context, Map<String, List<String>> params) {
debugPrint('未找到目标页');
return SCWidgetNotFound();
},
);
router.define(home,
handler: fluro.Handler(handlerFunc: (BuildContext? context, Map<String, List<String>> params) => SCIndexPage()));
router.define(
home,
handler: fluro.Handler(
handlerFunc:
(BuildContext? context, Map<String, List<String>> params) =>
SCIndexPage(),
),
);
_listRouter.clear();
@ -37,6 +46,7 @@ class SCRoutes {
_listRouter.add(SettingsRoute());
_listRouter.add(VoiceRoomRoute());
_listRouter.add(SCMainRoute());
_listRouter.add(VipRoute());
_listRouter.add(WalletRoute());
_listRouter.add(StoreRoute());
_listRouter.add(SCChatRouter());

View File

@ -1326,6 +1326,10 @@ class SCAppLocalizations {
String get example => translate('example');
String get endPreview => translate('endPreview');
String get selectAgain => translate('selectAgain');
String get touristsCannotSendMessages =>
translate('touristsCannotSendMessages');

View File

@ -604,13 +604,12 @@ class _SCEditProfilePageState extends State<SCEditProfilePage> {
context,
listen: false,
).uid;
// TODO(register): When the backend registration API supports invitation
// codes, pass `invitationCodeController.text.trim()` here as an optional
// argument. Empty string should remain allowed.
final invitationCode = invitationCodeController.text.trim();
SocialChatLoginRes user = await SCAccountRepository().regist(
authType,
idToken,
userProvider!.editUser!,
invitationCode: invitationCode,
);
final submittedProfile = userProvider?.editUser;
if (submittedProfile != null) {

View File

@ -0,0 +1,434 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/ui_kit/theme/socialchat_theme.dart';
import 'package:yumi/ui_kit/widgets/room/room_bottom_widget.dart';
import 'package:yumi/ui_kit/widgets/room/room_head_widget.dart';
import 'package:yumi/ui_kit/widgets/room/room_online_user_widget.dart';
class RoomBackgroundPreviewPage extends StatelessWidget {
const RoomBackgroundPreviewPage({super.key, this.backgroundPath});
final String? backgroundPath;
@override
Widget build(BuildContext context) {
final bottomPadding = MediaQuery.of(context).padding.bottom;
final localizations = SCAppLocalizations.of(context)!;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(child: _PreviewBackground(path: backgroundPath)),
Positioned.fill(child: _PreviewRoomLayer()),
Positioned(
left: 24.w,
right: 24.w,
bottom: 148.w + bottomPadding,
child: _PreviewPrimaryButton(
text: localizations.endPreview,
onTap: () => SCNavigatorUtils.goBackWithParams(context, true),
),
),
Positioned(
left: 24.w,
right: 24.w,
bottom: 90.w + bottomPadding,
child: _PreviewSecondaryButton(
text: localizations.selectAgain,
onTap: () => SCNavigatorUtils.goBackWithParams(context, false),
),
),
],
),
);
}
}
class _PreviewBackground extends StatelessWidget {
const _PreviewBackground({this.path});
final String? path;
@override
Widget build(BuildContext context) {
final value = (path ?? "").trim();
if (value.startsWith("http://") || value.startsWith("https://")) {
return Image.network(
value,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const _DefaultPreviewBackground(),
);
}
if (value.startsWith("assets/") || value.startsWith("sc_images/")) {
return Image.asset(
value,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const _DefaultPreviewBackground(),
);
}
if (value.isNotEmpty) {
final file = File(value);
if (file.existsSync()) {
return Image.file(
file,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const _DefaultPreviewBackground(),
);
}
}
return const _DefaultPreviewBackground();
}
}
class _DefaultPreviewBackground extends StatelessWidget {
const _DefaultPreviewBackground();
@override
Widget build(BuildContext context) {
return Image.asset(
SCGlobalConfig.businessLogicStrategy.getVoiceRoomDefaultBackgroundImage(),
fit: BoxFit.cover,
);
}
}
class _PreviewRoomLayer extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
SizedBox(height: ScreenUtil().setWidth(42)),
IgnorePointer(child: RoomHeadWidget()),
SizedBox(height: 5.w),
const IgnorePointer(child: RoomOnlineUserWidget()),
const IgnorePointer(child: _PreviewSeat10()),
Expanded(child: _PreviewChatArea()),
const IgnorePointer(child: RoomBottomWidget()),
],
);
}
}
class _PreviewSeat10 extends StatelessWidget {
const _PreviewSeat10();
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: List.generate(
5,
(index) => Expanded(
child: _PreviewSeatItem(index: index, locked: index == 1),
),
),
),
Row(
children: List.generate(5, (index) {
final seatIndex = index + 5;
return Expanded(
child: _PreviewSeatItem(
index: seatIndex,
locked: seatIndex == 6,
),
);
}),
),
],
),
);
}
}
class _PreviewSeatItem extends StatelessWidget {
const _PreviewSeatItem({required this.index, required this.locked});
final int index;
final bool locked;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 55.w,
height: 55.w,
child: Image.asset(
locked
? "sc_images/room/sc_icon_seat_lock.png"
: "sc_images/room/sc_icon_seat_open.png",
width: 52.w,
height: 52.w,
),
),
SizedBox(
height: 16.w,
child: Center(
child: Text(
"NO.${index + 1}",
style: TextStyle(
color: Colors.white,
fontSize: 10.sp,
fontWeight: FontWeight.w600,
),
),
),
),
],
);
}
}
class _PreviewChatArea extends StatefulWidget {
@override
State<_PreviewChatArea> createState() => _PreviewChatAreaState();
}
class _PreviewChatAreaState extends State<_PreviewChatArea> {
int _selectedTabIndex = 0;
void _selectTab(int index) {
if (_selectedTabIndex == index) {
return;
}
setState(() {
_selectedTabIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned(
left: 0,
right: 0,
top: 0,
child: _PreviewChatTabs(
selectedIndex: _selectedTabIndex,
onTap: _selectTab,
),
),
Positioned(
left: 24.w,
right: 24.w,
bottom: 166.w,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_PreviewChatBubble(text: "Hello My Friends!"),
SizedBox(height: 12.w),
_PreviewGiftBubble(),
],
),
),
],
);
}
}
class _PreviewChatTabs extends StatelessWidget {
const _PreviewChatTabs({required this.selectedIndex, required this.onTap});
final int selectedIndex;
final ValueChanged<int> onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 32.w,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 8.w),
itemCount: 3,
itemBuilder: (context, index) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onTap(index),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8.w),
child: Center(
child: Image.asset(
_assetFor(index, selectedIndex == index),
height: 26.w,
fit: BoxFit.contain,
),
),
),
);
},
),
);
}
String _assetFor(int index, bool selected) {
switch (index) {
case 0:
return selected
? "sc_images/room/sc_icon_room_chat_tab_all_selected.png"
: "sc_images/room/sc_icon_room_chat_tab_all_unselected.png";
case 1:
return selected
? "sc_images/room/sc_icon_room_chat_tab_chat_selected.png"
: "sc_images/room/sc_icon_room_chat_tab_chat_unselected.png";
case 2:
default:
return selected
? "sc_images/room/sc_icon_room_chat_tab_gift_selected.png"
: "sc_images/room/sc_icon_room_chat_tab_gift_unselected.png";
}
}
}
class _PreviewChatBubble extends StatelessWidget {
const _PreviewChatBubble({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(maxWidth: 276.w),
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.w),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.42),
borderRadius: BorderRadius.circular(10.w),
),
child: Text(
text,
style: TextStyle(
color: Colors.white,
fontSize: 13.sp,
fontWeight: FontWeight.w700,
),
),
);
}
}
class _PreviewGiftBubble extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(maxWidth: 300.w),
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.w),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.44),
borderRadius: BorderRadius.circular(10.w),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
"Send To User Name2",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 13.sp,
fontWeight: FontWeight.w700,
),
),
),
SizedBox(width: 10.w),
ClipOval(
child: Image.asset(
"sc_images/general/sc_icon_avar_defalt.png",
width: 32.w,
height: 32.w,
fit: BoxFit.cover,
),
),
SizedBox(width: 6.w),
Text(
"*1",
style: TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontWeight: FontWeight.w800,
),
),
],
),
);
}
}
class _PreviewPrimaryButton extends StatelessWidget {
const _PreviewPrimaryButton({required this.text, required this.onTap});
final String text;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
height: 48.w,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999.w),
gradient: LinearGradient(
colors: [SocialChatTheme.primaryLight, const Color(0xff8BF2D0)],
),
),
child: Text(
text,
style: TextStyle(
color: const Color(0xff0B2823),
fontSize: 18.sp,
fontWeight: FontWeight.w700,
),
),
),
);
}
}
class _PreviewSecondaryButton extends StatelessWidget {
const _PreviewSecondaryButton({required this.text, required this.onTap});
final String text;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
height: 48.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xff06372F).withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(999.w),
border: Border.all(color: SocialChatTheme.primaryLight, width: 1.w),
),
child: Text(
text,
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.w700,
),
),
),
);
}
}

View File

@ -82,6 +82,17 @@ class _RoomBackgroundSelectPageState extends State<RoomBackgroundSelectPage>
});
}
Future<void> _previewItem(List<_RoomBackgroundItem> items, int index) async {
final shouldUse = await VoiceRoomRoute.openRoomBackgroundPreview<bool>(
context,
backgroundPath: items[index].localImagePath,
);
if (!mounted || shouldUse != true) {
return;
}
_selectItem(items, index);
}
@override
Widget build(BuildContext context) {
final localizations = SCAppLocalizations.of(context)!;
@ -114,12 +125,12 @@ class _RoomBackgroundSelectPageState extends State<RoomBackgroundSelectPage>
_RoomBackgroundGrid(
items: _officialItems,
onTap:
(index) => _selectItem(_officialItems, index),
(index) => _previewItem(_officialItems, index),
bottomPadding: 24.w,
),
_RoomBackgroundGrid(
items: _mineItems,
onTap: (index) => _selectItem(_mineItems, index),
onTap: (index) => _previewItem(_mineItems, index),
bottomPadding: 100.w,
),
],

View File

@ -5,6 +5,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart';
import 'package:yumi/ui_kit/theme/socialchat_theme.dart';
import 'package:yumi/ui_kit/widgets/room/room_emoji_asset_image.dart';
import 'package:yumi/app/constants/sc_room_msg_type.dart';
import 'package:provider/provider.dart';
import 'package:yumi/app/constants/sc_screen.dart';
@ -475,8 +476,8 @@ class _EmoticonsState extends State<Emoticons> with TickerProviderStateMixin {
}
},
)
: netImage(
url: playingEvent?.number ?? "",
: _buildEmojiImage(
playingEvent?.number ?? "",
width: widget.isGameModel ? 65.w : 75.w,
)),
);
@ -495,6 +496,21 @@ class _EmoticonsState extends State<Emoticons> with TickerProviderStateMixin {
);
}
Widget _buildEmojiImage(String path, {required double width}) {
if (path.isEmpty) {
return SizedBox(width: width, height: width);
}
if (path.startsWith("assets/") || path.startsWith("sc_images/")) {
return RoomEmojiAssetImage(
key: ValueKey(path),
asset: path,
width: width,
height: width,
);
}
return netImage(url: path, width: width);
}
void _checkStart() {
if (!showIn) {
if (pathList.isNotEmpty) {

View File

@ -67,6 +67,14 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
_tabController = TabController(length: _pages.length, vsync: this);
_enableRoomVisualEffects();
_tabController.addListener(_handleTabChange);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
context.read<RtcProvider>().requestMicrophoneListRefresh(
notifyIfUnchanged: true,
);
});
_subscription = eventBus.on<SCGiveRoomLuckPageDisposeEvent>().listen((
event,

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:fluro/fluro.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:yumi/modules/room/background/room_background_preview_page.dart';
import 'package:yumi/modules/room/background/room_background_select_page.dart';
import 'package:yumi/modules/room/background/room_background_upload_page.dart';
import 'package:yumi/modules/room/edit/room_edit_page.dart';
@ -15,6 +16,7 @@ class VoiceRoomRoute implements SCIRouterProvider {
static String roomEdit = '/room/roomEdit';
static String roomTheme = '/room/roomTheme';
static String roomBackgroundSelect = '/room/background/select';
static String roomBackgroundPreview = '/room/background/preview';
static String roomBackgroundUpload = '/room/background/upload';
static Route<T> _buildRoute<T>(Widget page) {
@ -68,6 +70,16 @@ class VoiceRoomRoute implements SCIRouterProvider {
).push<T>(_buildRoute<T>(const RoomBackgroundUploadPage()));
}
static Future<T?> openRoomBackgroundPreview<T>(
BuildContext context, {
String? backgroundPath,
bool rootNavigator = false,
}) {
return Navigator.of(context, rootNavigator: rootNavigator).push<T>(
_buildRoute<T>(RoomBackgroundPreviewPage(backgroundPath: backgroundPath)),
);
}
@override
void initRouter(FluroRouter router) {
router.define(
@ -84,6 +96,12 @@ class VoiceRoomRoute implements SCIRouterProvider {
handlerFunc: (_, params) => const RoomBackgroundSelectPage(),
),
);
router.define(
roomBackgroundPreview,
handler: Handler(
handlerFunc: (_, params) => const RoomBackgroundPreviewPage(),
),
);
router.define(
roomBackgroundUpload,
handler: Handler(

View File

@ -7,6 +7,7 @@ import 'package:yumi/app_localizations.dart';
import 'package:yumi/modules/index/main_route.dart';
import 'package:yumi/modules/store/store_route.dart';
import 'package:yumi/modules/user/settings/settings_route.dart';
import 'package:yumi/modules/user/vip/vip_route.dart';
import 'package:yumi/modules/wallet/wallet_route.dart';
import 'package:yumi/services/auth/user_profile_manager.dart';
import 'package:yumi/shared/business_logic/models/res/sc_user_counter_res.dart';
@ -79,7 +80,7 @@ class _MePage2State extends State<MePage2> {
SizedBox(height: 12.w),
_buildEntryRow(),
SizedBox(height: 12.w),
_buildWalletCard(),
_buildWalletVipRow(),
SizedBox(height: 12.w),
_buildMenuCard2(),
SizedBox(height: 12.w),
@ -233,12 +234,21 @@ class _MePage2State extends State<MePage2> {
);
}
Widget _buildWalletVipRow() {
return Row(
children: [
Expanded(child: _buildVipEntryCard()),
SizedBox(width: 10.w),
Expanded(child: _buildWalletCard()),
],
);
}
Widget _buildWalletCard() {
return SCDebounceWidget(
onTap: () => SCNavigatorUtils.push(context, WalletRoute.recharge),
child: Container(
width: double.infinity,
height: 90.w,
height: 68.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.w),
image: const DecorationImage(
@ -246,44 +256,53 @@ class _MePage2State extends State<MePage2> {
fit: BoxFit.fill,
),
),
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.w),
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.w),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
SCAppLocalizations.of(context)!.wallet,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 24.sp,
fontSize: 18.sp,
fontWeight: FontWeight.w800,
fontStyle: FontStyle.italic,
color: Colors.white,
height: 1,
),
),
SizedBox(height: 4.w),
SizedBox(height: 2.w),
Row(
children: [
Image.asset(
'sc_images/general/sc_icon_jb.png',
width: 28.w,
height: 28.w,
'sc_images/general/sc_icon_wallet_coin.png',
width: 18.w,
height: 18.w,
),
SizedBox(width: 6.w),
Consumer<SocialChatUserProfileManager>(
builder: (context, ref, child) {
return Text(
':${_balanceText(ref.myBalance)}',
style: TextStyle(
color: Colors.white,
fontSize: 24.sp,
fontWeight: FontWeight.w700,
fontStyle: FontStyle.italic,
),
);
},
SizedBox(width: 4.w),
Expanded(
child: Consumer<SocialChatUserProfileManager>(
builder: (context, ref, child) {
return Text(
':${_balanceText(ref.myBalance)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.w700,
fontStyle: FontStyle.italic,
height: 1,
),
);
},
),
),
],
),
@ -292,8 +311,8 @@ class _MePage2State extends State<MePage2> {
),
Image.asset(
'sc_images/index/sc_icon_wallet_icon.png',
width: 68.w,
height: 68.w,
width: 40.w,
height: 40.w,
),
],
),
@ -301,6 +320,103 @@ class _MePage2State extends State<MePage2> {
);
}
Widget _buildVipEntryCard() {
return SCDebounceWidget(
onTap: () => SCNavigatorUtils.push(context, VipRoute.detail),
child: Container(
height: 68.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.w),
border: Border.all(color: const Color(0xFFFFD37A), width: 1.w),
image: const DecorationImage(
image: AssetImage('sc_images/vip/sc_vip_entry_bg.png'),
fit: BoxFit.fill,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 10.w,
offset: Offset(0, 4.w),
),
],
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
PositionedDirectional(
top: 12.w,
start: 14.w,
child: Text(
'VIP',
style: TextStyle(
color: Colors.white,
fontFamily: 'Source Han Sans SC',
fontSize: 16.sp,
fontWeight: FontWeight.w700,
height: 1,
),
),
),
PositionedDirectional(
start: 13.w,
bottom: 10.w,
child: _buildVipLevelMark(5),
),
PositionedDirectional(
end: 2.w,
top: 8.w,
child: Image.asset(
'sc_images/vip/sc_vip_badge_5.png',
width: 52.w,
height: 52.w,
fit: BoxFit.contain,
),
),
],
),
),
);
}
Widget _buildVipLevelMark(int level) {
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset(
'sc_images/vip/sc_vip_letter_v.png',
width: 18.1.w,
height: 15.3.w,
fit: BoxFit.contain,
),
Image.asset(
'sc_images/vip/sc_vip_letter_i.png',
width: 8.3.w,
height: 15.3.w,
fit: BoxFit.contain,
),
Image.asset(
'sc_images/vip/sc_vip_letter_p.png',
width: 18.1.w,
height: 15.3.w,
fit: BoxFit.contain,
),
SizedBox(width: 3.w),
_buildVipLevelNumber(level),
],
);
}
Widget _buildVipLevelNumber(int level) {
return Image.asset(
'sc_images/vip/sc_vip_number_${level}_filled.png',
height: 15.247.w,
fit: BoxFit.fill,
color: const Color(0xFFFFD155),
colorBlendMode: BlendMode.srcIn,
);
}
Widget _buildMenuCard2() {
final userProfile = context.watch<SocialChatUserProfileManager>();
final items = <_MenuRowData>[];
@ -343,7 +459,7 @@ class _MePage2State extends State<MePage2> {
if (userProfile.userIdentity?.bdLeader ?? false) {
items.add(
_MenuRowData(
title: SCAppLocalizations.of(context)!.bdLeader,
title: SCAppLocalizations.of(context)!.admin,
assetIcon: 'sc_images/index/sc_icon_bd_leader.png',
onTap: () {
SCNavigatorUtils.push(

View File

@ -0,0 +1,392 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
class VipBenefitPage extends StatefulWidget {
const VipBenefitPage({super.key, this.initialIndex = 0});
final int initialIndex;
@override
State<VipBenefitPage> createState() => _VipBenefitPageState();
}
class _VipBenefitPageState extends State<VipBenefitPage> {
static const List<_VipBenefitData> _benefits = [
_VipBenefitData(
title: 'Gif Profile Picture',
description: 'You can upload a Gif image as your profile picture',
icon: Icons.image_outlined,
),
_VipBenefitData(
title: 'Colorful Nickname',
description: 'Your nickname can use a special colorful display effect',
icon: Icons.notes_rounded,
),
_VipBenefitData(
title: 'Gif Room Picture',
description: 'You can use a Gif image as your room cover picture',
icon: Icons.home_rounded,
),
_VipBenefitData(
title: 'Colorful ID',
description: 'Your user ID can use a highlighted colorful style',
icon: Icons.pin_rounded,
isIdIcon: true,
),
];
late int _selectedIndex;
@override
void initState() {
super.initState();
_selectedIndex = widget.initialIndex.clamp(0, _benefits.length - 1).toInt();
}
@override
Widget build(BuildContext context) {
final selected = _benefits[_selectedIndex];
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: const [Color(0xFF0D2B22), Color(0xFF0E0E0E)],
stops: const [0, 0.40],
),
),
),
),
Positioned(
left: 0,
right: 0,
top: 0,
child: Image.asset(
'sc_images/vip/sc_vip_page_bg.png',
width: ScreenUtil().screenWidth,
fit: BoxFit.fitWidth,
alignment: Alignment.topCenter,
),
),
Positioned(
left: 0,
right: 0,
top: 0,
height: 300.w,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.36),
Colors.black.withValues(alpha: 0.62),
Colors.transparent,
],
stops: const [0, 0.52, 1],
),
),
),
),
),
SafeArea(
bottom: false,
child: Column(
children: [
_buildTopBar(context),
SizedBox(height: 18.w),
_buildBenefitTabs(),
SizedBox(height: 36.w),
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(36.w, 0, 36.w, 32.w),
child: _buildBenefitCard(selected),
),
),
],
),
),
],
),
);
}
Widget _buildTopBar(BuildContext context) {
return SizedBox(
height: 44.w,
child: Stack(
alignment: Alignment.center,
children: [
Align(
alignment: AlignmentDirectional.centerStart,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Navigator.pop(context),
child: SizedBox(
width: 44.w,
height: 44.w,
child: Icon(
SCGlobalConfig.lang == 'ar'
? Icons.keyboard_arrow_right
: Icons.keyboard_arrow_left,
color: Colors.white,
size: 28.w,
),
),
),
),
Text(
'VIP Benefits',
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
Widget _buildBenefitTabs() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (int index = 0; index < _benefits.length; index++)
_buildBenefitTab(index, _benefits[index]),
],
);
}
Widget _buildBenefitTab(int index, _VipBenefitData item) {
final selected = index == _selectedIndex;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _selectedIndex = index),
child: SizedBox(
width: 58.w,
child: Column(
children: [
Container(
width: 54.w,
height: 54.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
selected
? Colors.white.withValues(alpha: 0.08)
: Colors.transparent,
),
alignment: Alignment.center,
child: _buildBenefitIcon(item, size: 28.w),
),
SizedBox(height: 8.w),
AnimatedOpacity(
duration: const Duration(milliseconds: 160),
opacity: selected ? 1 : 0,
child: Container(
width: 20.w,
height: 3.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(999.w),
),
),
),
],
),
),
);
}
Widget _buildBenefitCard(_VipBenefitData item) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFF171717),
borderRadius: BorderRadius.circular(12.w),
),
padding: EdgeInsets.fromLTRB(14.w, 22.w, 14.w, 14.w),
child: Column(
children: [
Text(
item.title,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 20.sp,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 14.w),
Text(
item.description,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.58),
fontSize: 14.sp,
fontWeight: FontWeight.w400,
height: 1.22,
),
),
SizedBox(height: 18.w),
_buildPreviewPlaceholder(item),
],
),
);
}
Widget _buildPreviewPlaceholder(_VipBenefitData item) {
return Container(
width: double.infinity,
height: 260.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.w),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: const [Color(0xFF234F73), Color(0xFFF5F5F5)],
stops: const [0, 0.58],
),
),
child: Stack(
alignment: Alignment.center,
children: [
Positioned(
left: 18.w,
top: 16.w,
child: Icon(
SCGlobalConfig.lang == 'ar'
? Icons.keyboard_arrow_right
: Icons.keyboard_arrow_left,
color: Colors.white,
size: 24.w,
),
),
Positioned(
top: 80.w,
child: Container(
width: 64.w,
height: 64.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
border: Border.all(color: Colors.white, width: 2.w),
),
alignment: Alignment.center,
child: _buildBenefitIcon(item, size: 34.w),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
height: 110.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(18.w),
topRight: Radius.circular(18.w),
bottomLeft: Radius.circular(12.w),
bottomRight: Radius.circular(12.w),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
item.title,
style: TextStyle(
color: const Color(0xFF333333),
fontSize: 17.sp,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 10.w),
Container(
width: 88.w,
height: 18.w,
decoration: BoxDecoration(
color: const Color(0xFFF0F0F0),
borderRadius: BorderRadius.circular(9.w),
),
alignment: Alignment.center,
child: Text(
'Placeholder',
style: TextStyle(
color: const Color(0xFF555555),
fontSize: 9.sp,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
],
),
);
}
Widget _buildBenefitIcon(_VipBenefitData item, {required double size}) {
if (item.isIdIcon) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6.w),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFF7244), Color(0xFFFFD84E)],
),
),
alignment: Alignment.center,
child: Text(
'ID',
style: TextStyle(
color: Colors.white,
fontSize: (size * 0.42).sp,
fontWeight: FontWeight.w800,
height: 1,
),
),
);
}
return ShaderMask(
shaderCallback:
(bounds) => const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFF6F3D), Color(0xFFFFD84E)],
).createShader(bounds),
blendMode: BlendMode.srcIn,
child: Icon(item.icon, color: Colors.white, size: size),
);
}
}
class _VipBenefitData {
const _VipBenefitData({
required this.title,
required this.description,
required this.icon,
this.isIdIcon = false,
});
final String title;
final String description;
final IconData icon;
final bool isIdIcon;
}

View File

@ -0,0 +1,786 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/modules/user/vip/vip_route.dart';
class VipDetailPage extends StatefulWidget {
const VipDetailPage({super.key});
@override
State<VipDetailPage> createState() => _VipDetailPageState();
}
class _VipDetailPageState extends State<VipDetailPage> {
int _selectedLevel = 1;
late bool _showUpdateButton;
@override
void initState() {
super.initState();
_showUpdateButton = false;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: const [Color(0xFF0D2B22), Color(0xFF0E0E0E)],
stops: const [0, 0.40],
),
),
),
),
Positioned(
left: 0,
right: 0,
top: 0,
child: Image.asset(
'sc_images/vip/sc_vip_page_bg.png',
width: ScreenUtil().screenWidth,
fit: BoxFit.fitWidth,
alignment: Alignment.topCenter,
),
),
Positioned(
left: 0,
right: 0,
top: 0,
height: 300.w,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.36),
Colors.black.withValues(alpha: 0.62),
Colors.transparent,
],
stops: const [0, 0.52, 1],
),
),
),
),
),
SafeArea(
bottom: false,
child: Column(
children: [
_buildTopBar(),
_buildLevelTabs(),
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(12.w, 40.w, 12.w, 18.w),
child: Column(
children: [
_buildLevelCard(),
SizedBox(height: 30.w),
_buildLevelDivider(),
SizedBox(height: 3.w),
_buildSectionTitle('Decoration Privileges'),
SizedBox(height: 10.w),
_buildDecorationGrid(),
SizedBox(height: 20.w),
_buildSectionTitle('VIP Privileges'),
SizedBox(height: 14.w),
_buildPrivilegeGrid(),
SizedBox(height: 76.w),
],
),
),
),
_buildActivateBar(),
],
),
),
],
),
);
}
Widget _buildTopBar() {
return SizedBox(
height: 44.w,
child: Stack(
alignment: Alignment.center,
children: [
Align(
alignment: AlignmentDirectional.centerStart,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Navigator.pop(context),
child: SizedBox(
width: 44.w,
height: 44.w,
child: Icon(
SCGlobalConfig.lang == 'ar'
? Icons.keyboard_arrow_right
: Icons.keyboard_arrow_left,
color: Colors.white,
size: 28.w,
),
),
),
),
Text(
'VIP',
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.w500,
),
),
Align(
alignment: AlignmentDirectional.centerEnd,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => SCNavigatorUtils.push(context, VipRoute.instruction),
child: Padding(
padding: EdgeInsetsDirectional.only(end: 12.w),
child: Image.asset(
'sc_images/vip/sc_vip_question.png',
width: 24.w,
height: 24.w,
),
),
),
),
],
),
);
}
Widget _buildLevelTabs() {
return SizedBox(
height: 43.w,
child: ListView.separated(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: 12.w),
itemCount: 5,
separatorBuilder: (_, __) => SizedBox(width: 30.w),
itemBuilder: (context, index) {
final level = index + 1;
final selected = level == _selectedLevel;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _selectedLevel = level),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 4.w),
Text(
'VIP$level',
style: TextStyle(
color:
selected
? Colors.white
: Colors.white.withValues(alpha: 0.42),
fontSize: 15.sp,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 6.w),
SizedBox(
width: 0,
height: 9.5.w,
child: OverflowBox(
minWidth: 60.w,
maxWidth: 60.w,
alignment: Alignment.center,
child:
selected
? Image.asset(
'sc_images/vip/sc_vip_selected_line.png',
width: 60.w,
height: 9.5.w,
fit: BoxFit.fill,
)
: const SizedBox.shrink(),
),
),
],
),
);
},
),
);
}
Widget _buildLevelCard() {
final assetLevel = _assetLevel;
return Center(
child: SizedBox(
width: 351.w,
height: 124.5.w,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.w),
border: Border.all(
color: const Color(0xFFEBCB76),
width: 1.w,
),
image: DecorationImage(
image: AssetImage(
'sc_images/vip/sc_vip_card_bg_$assetLevel.png',
),
fit: BoxFit.fill,
),
),
),
),
PositionedDirectional(
start: 20.w,
top: 33.w,
child: _buildVipLevelMark(_selectedLevel, scale: 1.18),
),
PositionedDirectional(
start: 22.w,
top: 80.w,
child: Text(
'Not activated',
style: TextStyle(
color: const Color(0xFFF2C766).withValues(alpha: 0.78),
fontSize: 12.sp,
fontWeight: FontWeight.w500,
),
),
),
PositionedDirectional(
end: 24.w,
top: -36.w,
child: Image.asset(
'sc_images/vip/sc_vip_badge_$assetLevel.png',
width: 135.w,
height: 135.w,
fit: BoxFit.contain,
),
),
],
),
),
);
}
Widget _buildLevelDivider() {
final ornamentLayout = _ornamentLayoutForLevel(_assetLevel);
return SizedBox(
height: 38.w,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
Positioned(
left: -12.w,
right: -12.w,
top: 0,
height: 8.w,
child: Image.asset(
'sc_images/vip/sc_vip_top_border_$_assetLevel.png',
fit: BoxFit.fill,
),
),
Positioned(
left: 0,
right: 0,
top: ornamentLayout.top.w,
child: Transform.translate(
offset: Offset(ornamentLayout.dx.w, 0),
child: Center(
child: Image.asset(
'sc_images/vip/sc_vip_card_ornament_$_assetLevel.png',
width: ornamentLayout.width.w,
height: ornamentLayout.height.w,
fit: BoxFit.contain,
),
),
),
),
],
),
);
}
Widget _buildSectionTitle(String title) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'sc_images/vip/sc_vip_title_left.png',
width: 16.w,
height: 20.w,
fit: BoxFit.contain,
),
SizedBox(width: 8.w),
Text(
title,
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.w500,
),
),
SizedBox(width: 8.w),
Image.asset(
'sc_images/vip/sc_vip_title_right.png',
width: 16.w,
height: 20.w,
fit: BoxFit.contain,
),
],
);
}
Widget _buildDecorationGrid() {
final items = [
_VipFeatureData(
'SVIP Badge',
'sc_images/vip/sc_vip_badge_$_assetLevel.png',
),
_VipFeatureData('Frame', null, Icons.person_pin_circle_outlined),
_VipFeatureData('Mount', null, Icons.auto_awesome_motion),
_VipFeatureData('Mic Animation', null, Icons.radio_button_checked),
_VipFeatureData('Entry Effect', null, Icons.airline_seat_recline_extra),
];
return Wrap(
spacing: 10.w,
runSpacing: 10.w,
children:
items
.map(
(item) => _buildFeatureTile(
item,
width: item.title == 'Entry Effect' ? 206.w : 98.w,
height: 100.w,
),
)
.toList(),
);
}
Widget _buildPrivilegeGrid() {
final items = [
_VipFeatureData('Gif Profile\nPicture', null, Icons.badge_outlined),
_VipFeatureData('Colorful\nNickname', null, Icons.credit_card),
_VipFeatureData('Gif Room\nPicture', null, Icons.home_rounded),
_VipFeatureData('Colorful ID', null, Icons.pin_rounded),
];
return Wrap(
spacing: 34.w,
runSpacing: 24.w,
children:
items.asMap().entries.map((entry) {
final item = entry.value;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap:
() => SCNavigatorUtils.push(
context,
'${VipRoute.benefit}?index=${entry.key}',
),
child: SizedBox(
width: 74.w,
child: Column(
children: [
Container(
width: 46.w,
height: 46.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.08),
),
alignment: Alignment.center,
child: Icon(
item.icon,
color: const Color(0xFFFF8E3D),
size: 23.w,
),
),
SizedBox(height: 7.w),
Text(
item.title,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontWeight: FontWeight.w400,
height: 1.1,
),
),
],
),
),
);
}).toList(),
);
}
Widget _buildRewardPlaceholder(IconData icon) {
return Center(
child: Container(
width: 46.w,
height: 46.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
const Color(0xFFFFD155).withValues(alpha: 0.28),
Colors.white.withValues(alpha: 0.06),
],
),
border: Border.all(
color: const Color(0xFFFFD155).withValues(alpha: 0.45),
width: 0.8.w,
),
),
alignment: Alignment.center,
child: Icon(icon, color: const Color(0xFFFFD155), size: 24.w),
),
);
}
Widget _buildWideRewardPlaceholder() {
return Container(
width: double.infinity,
height: 40.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20.w),
gradient: LinearGradient(
colors: [
const Color(0xFFFFD155).withValues(alpha: 0.14),
const Color(0xFF7F4DFF).withValues(alpha: 0.16),
const Color(0xFFFFD155).withValues(alpha: 0.14),
],
),
border: Border.all(
color: const Color(0xFFFFD155).withValues(alpha: 0.35),
width: 0.8.w,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
for (int i = 0; i < 5; i++)
Container(
width: 4.w,
height: 4.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.45),
),
),
],
),
);
}
Widget _buildFeatureTile(
_VipFeatureData item, {
required double width,
required double height,
}) {
final isWide = item.title == 'Entry Effect';
return Container(
width: width,
height: height,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6.w),
border: Border.all(color: const Color(0xFFEBCB76), width: 1.w),
color: Colors.black.withValues(alpha: 0.38),
),
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 8.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (item.assetPath != null)
Expanded(child: Image.asset(item.assetPath!, fit: BoxFit.contain))
else
Expanded(
child:
isWide
? Center(child: _buildWideRewardPlaceholder())
: _buildRewardPlaceholder(
item.icon ?? Icons.auto_awesome,
),
),
SizedBox(height: 5.w),
Text(
item.title,
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontWeight: FontWeight.w400,
),
),
],
),
);
}
Widget _buildActivateBar() {
final bottomInset = MediaQuery.of(context).padding.bottom;
final actionButton = _actionButtonConfigForLevel(_assetLevel);
final barOverflow = 20.w + bottomInset;
return SizedBox(
height: 106.5.w,
width: double.infinity,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
right: 0,
top: 0,
bottom: -barOverflow,
child: ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20.w),
topRight: Radius.circular(20.w),
),
child: Image.asset(
'sc_images/vip/sc_vip_card_bg_$_assetLevel.png',
fit: BoxFit.fitWidth,
),
),
),
Positioned.fill(
child: Padding(
padding: EdgeInsets.fromLTRB(
30.w,
30.w,
30.w,
10.w + bottomInset,
),
child: Row(
children: [
Container(
width: 22.w,
height: 22.w,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFFFFA629),
),
alignment: Alignment.center,
child: Icon(
Icons.stars_rounded,
color: Colors.white,
size: 14.w,
),
),
SizedBox(width: 6.w),
Text(
'30.5 M / 30 Days',
style: TextStyle(
color: const Color(0xFFFFE8A7),
fontSize: 16.sp,
fontWeight: FontWeight.w700,
),
),
const Spacer(),
Container(
width: 106.w,
height: 42.w,
alignment: Alignment.center,
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(actionButton.backgroundAssetPath),
fit: BoxFit.fill,
),
),
child: Text(
actionButton.label,
textAlign: TextAlign.center,
style: TextStyle(
color: actionButton.textColor,
fontFamily: actionButton.fontFamily,
fontSize: actionButton.fontSize.sp,
fontStyle: FontStyle.normal,
fontWeight: FontWeight.w700,
height: 1,
),
),
),
],
),
),
),
],
),
);
}
Widget _buildVipLevelMark(int level, {double scale = 1}) {
return Transform.scale(
scale: scale,
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset(
'sc_images/vip/sc_vip_letter_v.png',
width: 28.w,
height: 24.7.w,
fit: BoxFit.fill,
),
Image.asset(
'sc_images/vip/sc_vip_letter_i.png',
width: 14.w,
height: 24.7.w,
fit: BoxFit.fill,
),
Image.asset(
'sc_images/vip/sc_vip_letter_p.png',
width: 28.w,
height: 24.7.w,
fit: BoxFit.fill,
),
SizedBox(width: 3.w),
_buildVipLevelNumber(level),
],
),
);
}
Widget _buildVipLevelNumber(int level) {
return Image.asset(
'sc_images/vip/sc_vip_number_${level}_filled.png',
height: 25.w,
fit: BoxFit.fill,
color: const Color(0xFFFFD155),
colorBlendMode: BlendMode.srcIn,
);
}
_VipOrnamentLayout _ornamentLayoutForLevel(int level) {
switch (level) {
case 1:
return const _VipOrnamentLayout(
width: 176.5,
height: 36,
top: -10,
dx: 0,
);
case 2:
return const _VipOrnamentLayout(
width: 246,
height: 61,
top: -20,
dx: 0,
);
case 3:
return const _VipOrnamentLayout(
width: 277,
height: 79.5,
top: -35,
dx: 0,
);
case 4:
return const _VipOrnamentLayout(
width: 240.5,
height: 79.5,
top: -35,
dx: 0,
);
case 5:
default:
return const _VipOrnamentLayout(
width: 282.5,
height: 101.5,
top: -48,
dx: 0,
);
}
}
_VipActionButtonConfig _actionButtonConfigForLevel(int level) {
switch (level) {
case 1:
case 2:
case 3:
case 4:
case 5:
default:
return _showUpdateButton
? const _VipActionButtonConfig.update()
: const _VipActionButtonConfig.activate();
}
}
int get _assetLevel => _selectedLevel.clamp(1, 5).toInt();
}
class _VipOrnamentLayout {
const _VipOrnamentLayout({
required this.width,
required this.height,
required this.top,
required this.dx,
});
final double width;
final double height;
final double top;
final double dx;
}
class _VipFeatureData {
const _VipFeatureData(this.title, this.assetPath, [this.icon]);
final String title;
final String? assetPath;
final IconData? icon;
}
class _VipActionButtonConfig {
const _VipActionButtonConfig({
required this.label,
required this.backgroundAssetPath,
required this.textColor,
required this.fontSize,
required this.fontFamily,
});
const _VipActionButtonConfig.activate()
: this(
label: 'Activate',
backgroundAssetPath: 'sc_images/vip/sc_vip_activate_button_bg.png',
textColor: const Color(0xFF7B4B16),
fontSize: 14,
fontFamily: null,
);
const _VipActionButtonConfig.update()
: this(
label: 'Update',
backgroundAssetPath: 'sc_images/vip/sc_vip_update_button_bg.png',
textColor: const Color(0xFF740000),
fontSize: 16,
fontFamily: 'Source Han Sans SC',
);
final String label;
final String backgroundAssetPath;
final Color textColor;
final double fontSize;
final String? fontFamily;
}

View File

@ -0,0 +1,491 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
class VipInstructionPage extends StatefulWidget {
const VipInstructionPage({super.key});
@override
State<VipInstructionPage> createState() => _VipInstructionPageState();
}
class _VipInstructionPageState extends State<VipInstructionPage> {
_VipInstructionTab _selectedTab = _VipInstructionTab.record;
static const List<_VipRecordRow> _recordRows = [
_VipRecordRow(
level: 1,
validDays: '30days',
type: 'Expiration',
time: '21/09/2029\n12:99:00',
),
_VipRecordRow(
level: 2,
validDays: '10days',
type: 'Card',
time: '21/09/2029\n12:99:00',
),
_VipRecordRow(
level: 3,
validDays: '30days',
type: 'Renew',
time: '21/09/2029\n12:99:00',
),
_VipRecordRow(
level: 4,
validDays: '30days',
type: 'Purchase',
time: '21/09/2029\n12:99:00',
),
_VipRecordRow(
level: 5,
validDays: '30days',
type: 'Upgrade',
time: '21/09/2029\n12:99:00',
),
];
static const List<_VipDescriptionSection> _descriptionSections = [
_VipDescriptionSection(
'Each time a VIP level is purchased, it is valid for 30 days, and can be manually renewed after expiration.',
),
_VipDescriptionSection(
'You can upgrade to a higher VIP level by paying the price difference. After upgrading, a new 30-day validity period will be given.',
bullets: [
'Price Difference = Target Price - Remaining Price for Current Days.',
'The upgrade takes effect immediately, and the remaining time of the previous level will be overwritten.',
],
),
_VipDescriptionSection(
'Once a higher-level VIP is activated, it is not possible to purchase a lower-level VIP.',
),
_VipDescriptionSection(
'Different levels cannot be stacked simultaneously; only one level can be activated at a time.',
),
_VipDescriptionSection(
'If using a card from your inventory to activate a new level:',
bullets: [
'If the new level is higher than the current level, it will directly upgrade, and the remaining time of the previous level will be cleared.',
'If the new level is lower than the current level, activation cannot be used.',
],
),
_VipDescriptionSection(
'After the VIP expires, the corresponding privileges will be lost, but the purchase records will not be cleared.',
),
_VipDescriptionSection(
'The platform reserves the final right to interpret the VIP rules.',
),
];
static const Color _tableLightColor = Color(0xFF262626);
static const Color _tableDarkColor = Color(0xFF191919);
static const Color _tableDividerColor = Color(0xFF333333);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: const [Color(0xFF0D2B22), Color(0xFF0E0E0E)],
stops: const [0, 0.40],
),
),
),
),
Positioned(
left: 0,
right: 0,
top: 0,
child: Image.asset(
'sc_images/vip/sc_vip_page_bg.png',
width: ScreenUtil().screenWidth,
fit: BoxFit.fitWidth,
alignment: Alignment.topCenter,
),
),
Positioned(
left: 0,
right: 0,
top: 0,
height: 300.w,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.36),
Colors.black.withValues(alpha: 0.62),
Colors.transparent,
],
stops: const [0, 0.52, 1],
),
),
),
),
),
SafeArea(
bottom: false,
child: Column(
children: [
_buildTopBar(context),
SizedBox(height: 6.w),
_buildTabs(),
Expanded(
child:
_selectedTab == _VipInstructionTab.record
? _buildRecordView()
: _buildDescriptionView(),
),
],
),
),
],
),
);
}
Widget _buildTopBar(BuildContext context) {
return SizedBox(
height: 44.w,
child: Stack(
alignment: Alignment.center,
children: [
Align(
alignment: AlignmentDirectional.centerStart,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Navigator.pop(context),
child: SizedBox(
width: 44.w,
height: 44.w,
child: Icon(
SCGlobalConfig.lang == 'ar'
? Icons.keyboard_arrow_right
: Icons.keyboard_arrow_left,
color: Colors.white,
size: 28.w,
),
),
),
),
Text(
'VIP Rules',
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
Widget _buildTabs() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildTab(_VipInstructionTab.record, 'Record'),
SizedBox(width: 45.5.w),
_buildTab(_VipInstructionTab.description, 'Description'),
],
);
}
Widget _buildTab(_VipInstructionTab tab, String title) {
final selected = _selectedTab == tab;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _selectedTab = tab),
child: Column(
children: [
Text(
title,
style: TextStyle(
color:
selected ? Colors.white : Colors.white.withValues(alpha: 0.5),
fontFamily: 'Source Han Sans SC',
fontSize: 18.sp,
fontStyle: FontStyle.normal,
fontWeight: FontWeight.w400,
height: 1,
),
),
SizedBox(height: 8.w),
AnimatedOpacity(
duration: const Duration(milliseconds: 160),
opacity: selected ? 1 : 0,
child: Container(
width: 18.w,
height: 4.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(999.w),
),
),
),
],
),
);
}
Widget _buildRecordView() {
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(16.w, 18.w, 16.w, 24.w),
child: _buildRecordTable(),
);
}
Widget _buildRecordTable() {
return ClipRRect(
borderRadius: BorderRadius.circular(12.w),
child: Container(
color: _tableDarkColor,
child: Column(
children: [
_buildTableRow(
const ['Level', 'Valid Days', 'type', 'Time'],
height: 38.5.w,
isHeader: true,
isLastRow: false,
),
for (int i = 0; i < _recordRows.length; i++)
_buildRecordDataRow(
_recordRows[i],
isLastRow: i == _recordRows.length - 1,
),
],
),
),
);
}
Widget _buildTableRow(
List<String> values, {
required double height,
required bool isHeader,
required bool isLastRow,
}) {
return SizedBox(
height: height,
child: Row(
children: List.generate(values.length, (index) {
return _buildTableCell(
columnIndex: index,
isLastColumn: index == values.length - 1,
isLastRow: isLastRow,
flex: _columnFlex(index),
child: Text(
values[index],
textAlign: TextAlign.center,
style: _tableTextStyle,
),
);
}),
),
);
}
Widget _buildRecordDataRow(_VipRecordRow row, {required bool isLastRow}) {
return SizedBox(
height: 76.w,
child: Row(
children: [
_buildTableCell(
columnIndex: 0,
isLastColumn: false,
isLastRow: isLastRow,
flex: _columnFlex(0),
child: Image.asset(
'sc_images/vip/sc_vip_badge_${row.level}.png',
width: 60.w,
height: 60.w,
fit: BoxFit.contain,
),
),
_buildTableCell(
columnIndex: 1,
isLastColumn: false,
isLastRow: isLastRow,
flex: _columnFlex(1),
child: Text(
row.validDays,
textAlign: TextAlign.center,
style: _tableTextStyle,
),
),
_buildTableCell(
columnIndex: 2,
isLastColumn: false,
isLastRow: isLastRow,
flex: _columnFlex(2),
child: Text(
row.type,
textAlign: TextAlign.center,
style: _tableTextStyle,
),
),
_buildTableCell(
columnIndex: 3,
isLastColumn: true,
isLastRow: isLastRow,
flex: _columnFlex(3),
child: Text(
row.time,
textAlign: TextAlign.center,
style: _tableTextStyle,
),
),
],
),
);
}
Widget _buildTableCell({
required int columnIndex,
required bool isLastColumn,
required bool isLastRow,
required int flex,
required Widget child,
}) {
return Expanded(
flex: flex,
child: Container(
alignment: Alignment.center,
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 8.w),
decoration: BoxDecoration(
color: columnIndex.isEven ? _tableLightColor : _tableDarkColor,
border: Border(
right:
isLastColumn
? BorderSide.none
: BorderSide(color: _tableDividerColor, width: 1.w),
bottom:
isLastRow
? BorderSide.none
: BorderSide(color: _tableDividerColor, width: 1.w),
),
),
child: child,
),
);
}
Widget _buildDescriptionView() {
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(16.w, 18.w, 16.w, 32.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < _descriptionSections.length; i++) ...[
_buildDescriptionSection(i + 1, _descriptionSections[i]),
SizedBox(height: 18.w),
],
],
),
);
}
Widget _buildDescriptionSection(int index, _VipDescriptionSection section) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$index. ${section.text}', style: _descriptionTextStyle),
if (section.bullets.isNotEmpty) ...[
SizedBox(height: 8.w),
for (final bullet in section.bullets) ...[
Padding(
padding: EdgeInsetsDirectional.only(start: 18.w),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('', style: _descriptionHighlightStyle),
SizedBox(width: 8.w),
Expanded(
child: Text(bullet, style: _descriptionHighlightStyle),
),
],
),
),
SizedBox(height: 4.w),
],
],
],
);
}
int _columnFlex(int index) {
switch (index) {
case 0:
return 92;
case 1:
return 100;
case 2:
return 114;
case 3:
default:
return 110;
}
}
TextStyle get _tableTextStyle => TextStyle(
color: Colors.white,
fontFamily: 'Source Han Sans SC',
fontSize: 12.sp,
fontStyle: FontStyle.normal,
fontWeight: FontWeight.w400,
height: 14.5 / 12,
);
TextStyle get _descriptionTextStyle => TextStyle(
color: Colors.white,
fontFamily: 'Source Han Sans SC',
fontSize: 16.sp,
fontStyle: FontStyle.normal,
fontWeight: FontWeight.w400,
height: 14.5 / 12,
);
TextStyle get _descriptionHighlightStyle => TextStyle(
color: const Color(0xFFFF9830),
fontFamily: 'Source Han Sans SC',
fontSize: 16.sp,
fontStyle: FontStyle.normal,
fontWeight: FontWeight.w400,
height: 14.5 / 12,
);
}
enum _VipInstructionTab { record, description }
class _VipRecordRow {
const _VipRecordRow({
required this.level,
required this.validDays,
required this.type,
required this.time,
});
final int level;
final String validDays;
final String type;
final String time;
}
class _VipDescriptionSection {
const _VipDescriptionSection(this.text, {this.bullets = const []});
final String text;
final List<String> bullets;
}

View File

@ -0,0 +1,32 @@
import 'package:fluro/fluro.dart';
import 'package:yumi/app/routes/sc_router_init.dart';
import 'package:yumi/modules/user/vip/vip_benefit_page.dart';
import 'package:yumi/modules/user/vip/vip_detail_page.dart';
import 'package:yumi/modules/user/vip/vip_instruction_page.dart';
class VipRoute implements SCIRouterProvider {
static String detail = '/main/me/vip/detail';
static String instruction = '/main/me/vip/instruction';
static String benefit = '/main/me/vip/benefit';
@override
void initRouter(FluroRouter router) {
router.define(
detail,
handler: Handler(handlerFunc: (_, params) => const VipDetailPage()),
);
router.define(
instruction,
handler: Handler(handlerFunc: (_, params) => const VipInstructionPage()),
);
router.define(
benefit,
handler: Handler(
handlerFunc:
(_, params) => VipBenefitPage(
initialIndex: int.tryParse(params['index']?.first ?? '') ?? 0,
),
),
);
}
}

View File

@ -63,6 +63,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
bool _isRefreshingOnlineUsers = false;
bool _disableMicListRefreshForCurrentSession = false;
bool _disableOnlineUsersRefreshForCurrentSession = false;
bool _isSelfMicActionInFlight = false;
bool _pendingMicListRefresh = false;
Timer? _deferredMicListRefreshTimer;
int _lastMicListRefreshStartedAtMs = 0;
@ -192,6 +193,17 @@ class RealTimeCommunicationManager extends ChangeNotifier {
return error.toString();
}
bool _isCancelledRequest(Object error) {
if (error is DioException) {
return error.type == DioExceptionType.cancel ||
(error.error?.toString().trim().toLowerCase() == 'cancel');
}
final normalizedMessage = error.toString().toLowerCase();
return normalizedMessage.contains('dioexceptiontype.cancel') ||
normalizedMessage.contains('error: cancel') ||
normalizedMessage.contains('request was manually cancelled');
}
bool _sameOnlineUsers(
List<SocialChatUserProfile> previous,
List<SocialChatUserProfile> next,
@ -1364,6 +1376,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
_resetLocalAudioRuntimeTracking();
_disableMicListRefreshForCurrentSession = false;
_disableOnlineUsersRefreshForCurrentSession = false;
_isSelfMicActionInFlight = false;
roomRocketStatus = null;
rtmProvider
?.onNewMessageListenerGroupMap["${currenRoom?.roomProfile?.roomProfile?.roomAccount}"] =
@ -1426,7 +1439,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
}
}
} else {
SocialChatUserProfile? roomWheatUser = roomWheatMap[index]?.user;
SocialChatUserProfile? roomWheatUser = micAtIndexForDisplay(index)?.user;
showBottomInBottomDialog(
context!,
EmptyMaiSelect(index: index, clickUser: roomWheatUser),
@ -1474,7 +1487,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
return false;
}
final seat = roomWheatMap[index];
final seat = micAtIndexForDisplay(index);
final seatUser = seat?.user;
if (seatUser == null) {
if (!(seat?.micLock ?? false) && !isRoomAdmin) {
@ -1550,6 +1563,10 @@ class RealTimeCommunicationManager extends ChangeNotifier {
}
}
if (_isSelfMicActionInFlight) {
return;
}
_isSelfMicActionInFlight = true;
try {
var micGoUpRes = await SCChatRoomRepository().micGoUp(
currenRoom?.roomProfile?.roomProfile?.id ?? "",
@ -1608,6 +1625,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
roomToken: micGoUpRes.roomToken,
);
}
_syncSelfMicRuntimeState();
if (roomWheatMap[targetIndex]?.micMute ?? false) {
///
if (isFz()) {
@ -1618,11 +1636,21 @@ class RealTimeCommunicationManager extends ChangeNotifier {
notifyListeners();
_refreshMicListSilently();
} catch (ex) {
SCTts.show('Failed to put on the microphone, $ex');
if (_isCancelledRequest(ex)) {
_refreshMicListSilently();
return;
}
SCTts.show('Failed to put on the microphone, ${_errorMessageFrom(ex)}');
} finally {
_isSelfMicActionInFlight = false;
}
}
xiaMai(num index) async {
if (_isSelfMicActionInFlight) {
return;
}
_isSelfMicActionInFlight = true;
try {
await SCChatRoomRepository().micGoDown(
currenRoom?.roomProfile?.roomProfile?.id ?? "",
@ -1648,13 +1676,20 @@ class RealTimeCommunicationManager extends ChangeNotifier {
engine?.renewToken("");
engine?.setClientRole(role: ClientRoleType.clientRoleAudience);
_clearUserFromSeats(currentUserId);
_syncSelfMicRuntimeState();
notifyListeners();
requestMicrophoneListRefresh(
notifyIfUnchanged: false,
minInterval: const Duration(milliseconds: 350),
);
} catch (ex) {
SCTts.show('Failed to leave the microphone, $ex');
if (_isCancelledRequest(ex)) {
_refreshMicListSilently();
return;
}
SCTts.show('Failed to leave the microphone, ${_errorMessageFrom(ex)}');
} finally {
_isSelfMicActionInFlight = false;
}
}

View File

@ -916,7 +916,8 @@ class RealTimeMessagingManager extends ChangeNotifier {
roomChatMsgList.removeAt(roomChatMsgList.length - 1);
}
msgChatListener?.call(msg);
} else if (msg.type == SCRoomMsgType.image) {
} else if (msg.type == SCRoomMsgType.image ||
(msg.type == SCRoomMsgType.emoticons && (msg.number ?? -1) < 0)) {
roomChatMsgList.insert(0, msg);
if (roomChatMsgList.length > 250) {
print('大于200条消息');
@ -1431,11 +1432,13 @@ class RealTimeMessagingManager extends ChangeNotifier {
return;
}
if (msg.type == SCRoomMsgType.emoticons) {
Provider.of<RealTimeCommunicationManager>(
context!,
listen: false,
).starPlayEmoji(msg);
return;
if ((msg.number ?? -1) >= 0) {
Provider.of<RealTimeCommunicationManager>(
context!,
listen: false,
).starPlayEmoji(msg);
return;
}
}
if (msg.type == SCRoomMsgType.micChange) {
Provider.of<RealTimeCommunicationManager>(

View File

@ -44,7 +44,7 @@ abstract class SocialChatUserRepository {
String openId,
SCUserProfileCmd userProfileCmd, {
SCMobileAuthCmd? mobileAuthCmd,
String invitePeople,
String invitationCode,
});
///

View File

@ -340,8 +340,13 @@ class BaseNetworkClient {
response: e.response,
error: '服务器错误: ${e.response?.data["errorCode"]}',
);
case DioExceptionType.cancel:
return DioException(requestOptions: e.requestOptions, error: 'Cancel');
case DioExceptionType.cancel:
return DioException(
requestOptions: e.requestOptions,
response: e.response,
type: DioExceptionType.cancel,
error: e.error ?? 'Cancel',
);
default:
return DioException(
requestOptions: e.requestOptions,

View File

@ -60,18 +60,16 @@ class SCAccountRepository implements SocialChatUserRepository {
String openId,
SCUserProfileCmd userProfileCmd, {
SCMobileAuthCmd? mobileAuthCmd,
String invitePeople = "",
String invitationCode = "",
}) async {
var param = {};
param["type"] = type;
param["openId"] = openId;
param["profile"] = userProfileCmd.toJson();
param["invitationCode"] = invitationCode;
if (mobileAuthCmd != null) {
param["mobile"] = mobileAuthCmd.toJson();
}
if (invitePeople.isNotEmpty) {
param["invitePeople"] = invitePeople;
}
final result = await http.post(
"aa35848b37e5ba74d93e5a1f719c579cb289b5940c15040473b6f57550de35df",
data: param,

View File

@ -40,30 +40,20 @@ class RoomBottomChatEntry extends StatelessWidget {
],
),
child: Padding(
padding: EdgeInsetsDirectional.only(start: 14.w, end: 14.w),
child: Row(
children: [
Image.asset(
'sc_images/room/icon_room_input_t.png',
width: 22.w,
height: 22.w,
fit: BoxFit.contain,
padding: EdgeInsetsDirectional.only(start: 16.w, end: 16.w),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
localizations?.translate('roomBottomGreeting') ?? 'Hi...',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 15.sp,
fontWeight: FontWeight.w700,
height: 1.0,
),
SizedBox(width: 10.w),
Expanded(
child: Text(
localizations?.translate('roomBottomGreeting') ?? 'Hi...',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 15.sp,
fontWeight: FontWeight.w700,
height: 1.0,
),
),
),
],
),
),
),
),

View File

@ -1,9 +1,14 @@
import 'package:flutter/cupertino.dart';
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_svga/flutter_svga.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/shared/tools/sc_network_image_utils.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart';
class LuckGiftNomorAnimWidget extends StatefulWidget {
@ -18,22 +23,9 @@ 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;
static const String _avatarDynamicKey = "avatar";
static const String _awardAmountDynamicKey = "digit01";
static const String _multipleDynamicKey = "digit02";
bool _shouldPlayRewardBurst(Data rewardData) {
return RealTimeMessagingManager.shouldPlayLuckyGiftBurst(rewardData);
@ -56,38 +48,201 @@ class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
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),
Future<void> _configureRewardBurstSvga(
MovieEntity movieEntity,
Data rewardData,
) async {
final avatarUrl = (rewardData.userAvatar ?? '').trim();
if (avatarUrl.isNotEmpty) {
try {
final avatarImage = await _buildAvatarLayerImage(avatarUrl);
if (avatarImage != null) {
movieEntity.dynamicItem.setImage(avatarImage, _avatarDynamicKey);
}
} catch (error) {
debugPrint('[LuckGiftBurstSVGA] avatar sync failed: $error');
}
}
try {
final awardAmountImage = await _buildTextLayerImage(
text: _formatAwardAmount(rewardData.awardAmount ?? 0),
logicalWidth: 140,
logicalHeight: 65,
fontSize: 42,
color: const Color(0xFFFFF3B6),
shadowColor: const Color(0xCC7A3E00),
);
movieEntity.dynamicItem.setImage(
awardAmountImage,
_awardAmountDynamicKey,
);
} catch (error) {
debugPrint('[LuckGiftBurstSVGA] award amount sync failed: $error');
}
final multiple = rewardData.multiple ?? 0;
if (multiple > 0) {
try {
final multipleImage = await _buildTextLayerImage(
text: 'x${_formatMultiple(multiple)}',
logicalWidth: 166,
logicalHeight: 37,
fontSize: 28,
color: const Color(0xFFF4F6FF),
shadowColor: const Color(0x990B2D72),
);
movieEntity.dynamicItem.setImage(multipleImage, _multipleDynamicKey);
} catch (error) {
debugPrint('[LuckGiftBurstSVGA] multiple sync failed: $error');
}
}
}
Future<ui.Image?> _buildAvatarLayerImage(String avatarUrl) async {
final avatarImage = await _loadUiImage(
avatarUrl,
logicalWidth: 100,
logicalHeight: 100,
);
if (avatarImage == null) {
return null;
}
const double size = 100;
const double pixelRatio = 3;
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.scale(pixelRatio, pixelRatio);
final rect = Rect.fromLTWH(0, 0, size, size);
final center = rect.center;
canvas.drawCircle(
center,
size / 2,
Paint()
..color = const Color(0xFFFFD680)
..isAntiAlias = true,
);
canvas.save();
canvas.clipPath(Path()..addOval(rect.deflate(5)));
paintImage(
canvas: canvas,
rect: rect.deflate(5),
image: avatarImage,
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
);
canvas.restore();
final picture = recorder.endRecording();
return picture.toImage(
(size * pixelRatio).round(),
(size * pixelRatio).round(),
);
}
Future<ui.Image> _buildTextLayerImage({
required String text,
required double logicalWidth,
required double logicalHeight,
required double fontSize,
required Color color,
required Color shadowColor,
}) async {
const double pixelRatio = 3;
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.scale(pixelRatio, pixelRatio);
canvas.clipRect(Rect.fromLTWH(0, 0, logicalWidth, logicalHeight));
final textPainter = TextPainter(
textDirection: TextDirection.ltr,
maxLines: 1,
textAlign: TextAlign.center,
text: TextSpan(
text: text,
style: TextStyle(
fontSize: fontSize,
color: color,
fontWeight: FontWeight.w900,
fontStyle: FontStyle.italic,
height: 1,
decoration: TextDecoration.none,
shadows: [
Shadow(
color: shadowColor,
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: ClipOval(
child: netImage(
url: avatarUrl,
width: innerSize,
height: innerSize,
fit: BoxFit.cover,
shape: BoxShape.circle,
),
),
),
)..layout();
final maxTextWidth = logicalWidth - 8;
final maxTextHeight = logicalHeight - 6;
final scale = math.min(
1.0,
math.min(
maxTextWidth / math.max(textPainter.width, 1),
maxTextHeight / math.max(textPainter.height, 1),
),
);
canvas.save();
canvas.translate(logicalWidth / 2, logicalHeight / 2);
canvas.scale(scale, scale);
textPainter.paint(
canvas,
Offset(-textPainter.width / 2, -textPainter.height / 2),
);
canvas.restore();
final picture = recorder.endRecording();
return picture.toImage(
(logicalWidth * pixelRatio).round(),
(logicalHeight * pixelRatio).round(),
);
}
Future<ui.Image?> _loadUiImage(
String resource, {
double? logicalWidth,
double? logicalHeight,
}) async {
final target = resource.trim();
if (target.isEmpty) {
return null;
}
final provider = buildCachedImageProvider(
target,
logicalWidth: logicalWidth,
logicalHeight: logicalHeight,
);
final stream = provider.resolve(ImageConfiguration.empty);
final completer = Completer<ui.Image?>();
late final ImageStreamListener listener;
listener = ImageStreamListener(
(imageInfo, synchronousCall) {
if (!completer.isCompleted) {
completer.complete(imageInfo.image);
}
stream.removeListener(listener);
},
onError: (Object error, StackTrace? stackTrace) {
if (!completer.isCompleted) {
completer.complete(null);
}
stream.removeListener(listener);
},
);
stream.addListener(listener);
return completer.future.timeout(
const Duration(seconds: 2),
onTimeout: () {
stream.removeListener(listener);
return null;
},
);
}
@ -98,32 +253,8 @@ class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
builder: (context, provider, child) {
final rewardData = provider.currentPlayingLuckGift?.data;
if (rewardData == null || !_shouldPlayRewardBurst(rewardData)) {
_currentBurstEventId = null;
_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 ?? ""}'
@ -131,132 +262,23 @@ class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
'|${rewardData.multiple ?? 0}'
'|${rewardData.normalizedMultipleType}',
);
if (_currentBurstEventId != rewardAnimationKey.value) {
_currentBurstEventId = rewardAnimationKey.value;
_isRewardAmountVisible = false;
}
return SizedBox(
height: 380.w,
child: Stack(
alignment: Alignment.center,
children: [
Padding(
padding: EdgeInsets.only(top: 10.w),
child: SCSvgaAssetWidget(
key: ValueKey<String>('burst|${rewardAnimationKey.value}'),
assetPath: LuckGiftNomorAnimWidget._rewardBurstAssetPath,
width: ScreenUtil().screenWidth,
height: 380.w,
fit: BoxFit.fitWidth,
allowDrawingOverflow: true,
clearsAfterStop: true,
onPlaybackStarted: () {
if (!mounted) {
return;
}
if (!_isRewardAmountVisible) {
setState(() {
_isRewardAmountVisible = true;
});
}
},
onPlaybackCompleted: () {
if (!mounted) {
return;
}
if (_isRewardAmountVisible) {
setState(() {
_isRewardAmountVisible = false;
});
}
},
),
),
if (_isRewardAmountVisible &&
(rewardData.userAvatar ?? '').trim().isNotEmpty)
Positioned(
left: avatarLeft,
top: avatarTop,
child: _buildSenderAvatar(
avatarUrl: (rewardData.userAvatar ?? '').trim(),
outerSize: avatarDiameter,
),
),
if (_isRewardAmountVisible)
Positioned(
top: awardTop,
left: 0,
right: 0,
child: Transform.translate(
offset: Offset(awardXOffset, 0),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: screenWidth * 0.34,
),
child: FittedBox(
fit: BoxFit.scaleDown,
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),
),
],
),
),
),
),
),
),
),
],
child: Padding(
padding: EdgeInsets.only(top: 10.w),
child: SCSvgaAssetWidget(
key: ValueKey<String>('burst|${rewardAnimationKey.value}'),
assetPath: LuckGiftNomorAnimWidget._rewardBurstAssetPath,
width: ScreenUtil().screenWidth,
height: 380.w,
fit: BoxFit.fitWidth,
allowDrawingOverflow: true,
clearsAfterStop: true,
dynamicIdentity: rewardAnimationKey.value,
movieConfigurer:
(movieEntity) =>
_configureRewardBurstSvga(movieEntity, rewardData),
),
),
);
},

View File

@ -70,7 +70,7 @@ class EmptyMaiSelect extends StatelessWidget {
}
}
} else {
MicRes? userWheat = provider.roomWheatMap[index];
MicRes? userWheat = provider.micAtIndexForDisplay(index);
if (userWheat?.user?.id ==
AccountStorage().getCurrentUser()?.userProfile?.id) {
if (provider.isFz()) {
@ -248,7 +248,7 @@ class EmptyMaiSelect extends StatelessWidget {
() {
Navigator.of(context).pop();
showBottomInCenterDialog(
context!,
context,
RoomUserInfoCard(userId: clickUser?.id),
);
},

View File

@ -37,6 +37,8 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
static const double _floatingButtonBottomOffset = 54;
static const double _giftActionWidth = 52;
static const double _circleActionWidth = 46;
static const double _emojiActionWidth = 38;
static const double _chatEmojiGap = 8;
static const double _compactGap = 14;
static const double _horizontalPadding = 16;
@ -59,7 +61,10 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
builder: (context, bottomSnapshot, child) {
return LayoutBuilder(
builder: (context, constraints) {
final inputWidth = constraints.maxWidth / 3;
final inputWidth = _resolveInputWidth(
maxWidth: constraints.maxWidth,
showMic: bottomSnapshot.showMic,
);
final giftCenterX = _resolveGiftCenterX(
maxWidth: constraints.maxWidth,
inputWidth: inputWidth,
@ -107,6 +112,17 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
);
}
Widget _buildChatControls(double inputWidth) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildChatEntry(inputWidth),
SizedBox(width: _chatEmojiGap.w),
_buildEmojiAction(),
],
);
}
Widget _buildBottomBar({
required double inputWidth,
required bool showMic,
@ -127,7 +143,7 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildChatEntry(inputWidth),
_buildChatControls(inputWidth),
giftAction,
_buildMicAction(isMic: isMic),
menuAction,
@ -137,7 +153,7 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildChatEntry(inputWidth),
_buildChatControls(inputWidth),
const Spacer(),
giftAction,
SizedBox(width: _compactGap.w),
@ -157,6 +173,44 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
);
}
Widget _buildEmojiAction() {
return SizedBox(
width: _emojiActionWidth.w,
height: 48.w,
child: SCDebounceWidget(
onTap: () {
if (SCRoomUtils.touristCanMsg(context)) {
Navigator.push(
context,
PopRoute(child: const RoomMsgInput(initialShowEmoji: true)),
);
}
},
child: Center(
child: Image.asset(
"sc_images/room/sc_icon_room_bottom_emoji.png",
width: 34.w,
height: 34.w,
fit: BoxFit.contain,
gaplessPlayback: true,
),
),
),
);
}
double _resolveInputWidth({required double maxWidth, required bool showMic}) {
final contentWidth = maxWidth - (_horizontalPadding.w * 2);
final actionWidth =
_emojiActionWidth.w +
_chatEmojiGap.w +
_giftActionWidth.w +
_circleActionWidth.w * (showMic ? 3 : 2) +
(showMic ? 16.w : _compactGap.w * 2);
final availableWidth = contentWidth - actionWidth;
return availableWidth.clamp(82.w, 112.w).toDouble();
}
double _resolveGiftCenterX({
required double maxWidth,
required double inputWidth,
@ -165,17 +219,28 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
final contentWidth = maxWidth - (_horizontalPadding.w * 2);
if (showMic) {
final occupiedWidth =
inputWidth + _giftActionWidth.w + _circleActionWidth.w * 3;
inputWidth +
_chatEmojiGap.w +
_emojiActionWidth.w +
_giftActionWidth.w +
_circleActionWidth.w * 3;
final gapCount = 4;
final gap = ((contentWidth - occupiedWidth) / gapCount).clamp(
0.0,
double.infinity,
);
return _horizontalPadding.w + inputWidth + gap + (_giftActionWidth.w / 2);
return _horizontalPadding.w +
inputWidth +
_chatEmojiGap.w +
_emojiActionWidth.w +
gap +
(_giftActionWidth.w / 2);
}
final fixedWidth =
inputWidth +
_chatEmojiGap.w +
_emojiActionWidth.w +
_giftActionWidth.w +
_circleActionWidth.w * 2 +
_compactGap.w * 2;

View File

@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
class RoomEmojiAssetImage extends StatefulWidget {
const RoomEmojiAssetImage({
super.key,
required this.asset,
required this.width,
required this.height,
this.fit = BoxFit.contain,
});
final String asset;
final double width;
final double height;
final BoxFit fit;
@override
State<RoomEmojiAssetImage> createState() => _RoomEmojiAssetImageState();
}
class _RoomEmojiAssetImageState extends State<RoomEmojiAssetImage>
with AutomaticKeepAliveClientMixin {
late AssetImage _imageProvider;
@override
void initState() {
super.initState();
_imageProvider = AssetImage(widget.asset);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_precache();
}
@override
void didUpdateWidget(covariant RoomEmojiAssetImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.asset != widget.asset) {
_imageProvider = AssetImage(widget.asset);
_precache();
}
}
@override
Widget build(BuildContext context) {
super.build(context);
return RepaintBoundary(
child: Image(
image: _imageProvider,
width: widget.width,
height: widget.height,
fit: widget.fit,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
),
);
}
void _precache() {
precacheImage(_imageProvider, context);
}
@override
bool get wantKeepAlive => true;
}

View File

@ -1,10 +1,8 @@
import 'package:extended_text/extended_text.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_emoji_datas.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';
@ -14,21 +12,51 @@ import 'package:yumi/services/audio/rtm_manager.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});
const RoomMsgInput({
super.key,
this.atTextContent,
this.initialShowEmoji = false,
});
@override
State<RoomMsgInput> createState() => _RoomMsgInputState();
}
class _RoomMsgInputState extends State<RoomMsgInput> {
static const List<String> _roomEmojiAssets = [
"sc_images/room/emoji/fluent_emoji_01.webp",
"sc_images/room/emoji/fluent_emoji_02.webp",
"sc_images/room/emoji/fluent_emoji_03.webp",
"sc_images/room/emoji/fluent_emoji_04.webp",
"sc_images/room/emoji/fluent_emoji_05.webp",
"sc_images/room/emoji/fluent_emoji_06.webp",
"sc_images/room/emoji/fluent_emoji_07.webp",
"sc_images/room/emoji/fluent_emoji_08.webp",
"sc_images/room/emoji/fluent_emoji_09.webp",
"sc_images/room/emoji/fluent_emoji_10.webp",
"sc_images/room/emoji/monkey_1.gif",
"sc_images/room/emoji/monkey_2.gif",
"sc_images/room/emoji/monkey_3.gif",
"sc_images/room/emoji/monkey_4.gif",
"sc_images/room/emoji/monkey_5.gif",
"sc_images/room/emoji/monkey_6.gif",
"sc_images/room/emoji/monkey_7.gif",
"sc_images/room/emoji/monkey_8.gif",
"sc_images/room/emoji/monkey_9.gif",
"sc_images/room/emoji/monkey_10.gif",
];
bool showSend = false;
bool showEmoji = false;
bool _emojiAssetsPrecached = false;
final FocusNode msgNode = FocusNode();
final TextEditingController controller = TextEditingController();
@ -39,6 +67,7 @@ class _RoomMsgInputState extends State<RoomMsgInput> {
@override
void initState() {
super.initState();
showEmoji = widget.initialShowEmoji;
if (widget.atTextContent != null) {
controller.value = TextEditingValue(text: widget.atTextContent ?? "");
showSend = controller.text.trim().isNotEmpty;
@ -52,6 +81,14 @@ class _RoomMsgInputState extends State<RoomMsgInput> {
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (showEmoji) {
_precacheRoomEmojis();
}
}
@override
Widget build(BuildContext context) {
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
@ -101,8 +138,8 @@ class _RoomMsgInputState extends State<RoomMsgInput> {
child: Image.asset(
showEmoji
? _strategy.getSCMessageChatPageChatKeyboardIcon()
: _strategy.getSCMessageChatPageEmojiIcon(),
color: Colors.white,
: "sc_images/room/sc_icon_room_bottom_emoji.png",
color: showEmoji ? Colors.white : null,
fit: BoxFit.contain,
),
),
@ -135,7 +172,7 @@ class _RoomMsgInputState extends State<RoomMsgInput> {
widget.atTextContent != null ? TextDirection.ltr : null,
specialTextSpanBuilder: AtTextSpanBuilder(),
focusNode: msgNode,
autofocus: true,
autofocus: !widget.initialShowEmoji,
textInputAction: TextInputAction.send,
onTap: () {
if (showEmoji) {
@ -210,16 +247,21 @@ class _RoomMsgInputState extends State<RoomMsgInput> {
crossAxisSpacing: 8.w,
mainAxisSpacing: 8.w,
),
itemCount: SCEmojiDatas.smileys.length,
itemCount: _roomEmojiAssets.length,
itemBuilder: (context, index) {
final emoji = SCEmojiDatas.smileys[index];
final emojiAsset = _roomEmojiAssets[index];
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
_insertEmoji(emoji);
_sendRoomEmoji(emojiAsset);
},
child: Center(
child: ExtendedText(emoji, style: TextStyle(fontSize: sp(15))),
child: RoomEmojiAssetImage(
key: ValueKey(emojiAsset),
asset: emojiAsset,
width: 30.w,
height: 30.w,
),
),
);
},
@ -237,30 +279,47 @@ class _RoomMsgInputState extends State<RoomMsgInput> {
}
SCKeybordUtil.hide(context);
_precacheRoomEmojis();
setState(() {
showEmoji = true;
});
}
void _insertEmoji(String emoji) {
final value = controller.value;
final selection = value.selection;
final fallbackOffset = value.text.length;
final start = selection.isValid ? selection.start : fallbackOffset;
final end = selection.isValid ? selection.end : fallbackOffset;
final safeStart = start < 0 ? fallbackOffset : start;
final safeEnd = end < 0 ? fallbackOffset : end;
final nextText = value.text.replaceRange(safeStart, safeEnd, emoji);
void _precacheRoomEmojis() {
if (_emojiAssetsPrecached) {
return;
}
_emojiAssetsPrecached = true;
for (final asset in _roomEmojiAssets) {
precacheImage(AssetImage(asset), context);
}
}
controller.value = value.copyWith(
text: nextText,
selection: TextSelection.collapsed(offset: safeStart + emoji.length),
composing: TextRange.empty,
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,
);
setState(() {
showSend = controller.text.trim().isNotEmpty;
});
if (seatIndex > -1) {
rtcProvider.starPlayEmoji(msg);
}
Provider.of<RtmProvider>(
context,
listen: false,
).dispatchMessage(msg, addLocal: seatIndex < 0);
}
void _sendMessage() {

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,8 @@ class RoomSeatWidget extends StatefulWidget {
}
class _RoomSeatWidgetState extends State<RoomSeatWidget> {
static const int _defaultVoiceRoomSeatCount = 10;
int _lastSeatCount = 0;
int _normalizeSeatCount(int? seatCount) {
@ -40,19 +42,40 @@ class _RoomSeatWidgetState extends State<RoomSeatWidget> {
if (snapshot.isExitingCurrentVoiceRoomSession && _lastSeatCount > 0) {
return _lastSeatCount;
}
if (snapshot.hasCurrentRoom && seatCount == 0) {
return _lastSeatCount > 0 ? _lastSeatCount : _defaultVoiceRoomSeatCount;
}
return seatCount;
}
int _seatCountFromMicIndexes(Iterable<num> micIndexes) {
var maxIndex = -1;
var count = 0;
for (final micIndex in micIndexes) {
count += 1;
final resolvedIndex = micIndex.toInt();
if (resolvedIndex > maxIndex) {
maxIndex = resolvedIndex;
}
}
final indexedSeatCount = _normalizeSeatCount(maxIndex + 1);
if (indexedSeatCount > 0) {
return indexedSeatCount;
}
return _normalizeSeatCount(count);
}
@override
Widget build(BuildContext context) {
return Selector<RtcProvider, _RoomSeatLayoutSnapshot>(
selector:
(context, provider) => _RoomSeatLayoutSnapshot(
seatCount: provider.roomWheatMap.length,
seatCount: _seatCountFromMicIndexes(provider.roomWheatMap.keys),
configuredSeatCount:
provider.currenRoom?.roomProfile?.roomSetting?.mikeSize
?.toInt() ??
0,
hasCurrentRoom: provider.currenRoom != null,
isExitingCurrentVoiceRoomSession:
provider.isExitingCurrentVoiceRoomSession,
),
@ -211,11 +234,13 @@ class _RoomSeatLayoutSnapshot {
const _RoomSeatLayoutSnapshot({
required this.seatCount,
required this.configuredSeatCount,
required this.hasCurrentRoom,
required this.isExitingCurrentVoiceRoomSession,
});
final int seatCount;
final int configuredSeatCount;
final bool hasCurrentRoom;
final bool isExitingCurrentVoiceRoomSession;
@override
@ -226,6 +251,7 @@ class _RoomSeatLayoutSnapshot {
return other is _RoomSeatLayoutSnapshot &&
other.seatCount == seatCount &&
other.configuredSeatCount == configuredSeatCount &&
other.hasCurrentRoom == hasCurrentRoom &&
other.isExitingCurrentVoiceRoomSession ==
isExitingCurrentVoiceRoomSession;
}
@ -234,6 +260,7 @@ class _RoomSeatLayoutSnapshot {
int get hashCode => Object.hash(
seatCount,
configuredSeatCount,
hasCurrentRoom,
isExitingCurrentVoiceRoomSession,
);
}

View File

@ -161,6 +161,7 @@ flutter:
- sc_images/daily_sign_in/
- sc_images/register_reward/
- sc_images/room/
- sc_images/room/emoji/
- sc_images/room/entrance/
- sc_images/room/anim/
- sc_images/room/anim/gift/
@ -170,4 +171,5 @@ flutter:
- sc_images/msg/
- sc_images/level/
- sc_images/coupon/
- sc_images/vip/
- fonts/

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1004 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Some files were not shown because too many files have changed in this diff Show More