bgm播放器 进厨房bug fix

This commit is contained in:
roxy 2026-04-29 18:28:39 +08:00
parent b0d68d2756
commit 62d6405aa0
17 changed files with 1479 additions and 198 deletions

View File

@ -25,13 +25,17 @@ class SCRoomFollowPage extends SCPageList {
class _RoomFollowPageState class _RoomFollowPageState
extends SCPageListState<FollowRoomRes, SCRoomFollowPage> extends SCPageListState<FollowRoomRes, SCRoomFollowPage>
with WidgetsBindingObserver { with WidgetsBindingObserver, AutomaticKeepAliveClientMixin {
static const Duration _roomListRefreshInterval = Duration(seconds: 15); static const Duration _roomListRefreshInterval = Duration(seconds: 15);
String? lastId; String? lastId;
Timer? _roomListRefreshTimer; Timer? _roomListRefreshTimer;
bool _isSilentRefreshingRooms = false; bool _isSilentRefreshingRooms = false;
bool _wasTickerModeEnabled = false; bool _wasTickerModeEnabled = false;
bool _hasCompletedInitialLoad = false;
@override
bool get wantKeepAlive => true;
@override @override
void initState() { void initState() {
@ -71,11 +75,12 @@ class _RoomFollowPageState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context);
return Scaffold( return Scaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
body: body:
items.isEmpty && isLoading !_hasCompletedInitialLoad && items.isEmpty && isLoading
? const SCHomeSkeletonShimmer( ? const SCHomeSkeletonShimmer(
builder: _buildFollowSkeletonContent, builder: _buildFollowSkeletonContent,
) )
@ -322,8 +327,14 @@ class _RoomFollowPageState
if (roomList.isNotEmpty) { if (roomList.isNotEmpty) {
lastId = roomList.last.id; lastId = roomList.last.id;
} }
if (page == 1) {
_hasCompletedInitialLoad = true;
}
onSuccess(roomList); onSuccess(roomList);
} catch (e) { } catch (e) {
if (page == 1) {
_hasCompletedInitialLoad = true;
}
if (onErr != null) { if (onErr != null) {
onErr(); onErr();
} }
@ -380,9 +391,8 @@ class _RoomFollowPageState
} }
bool changed = false; bool changed = false;
final replaceCount = latestRooms.length < items.length final replaceCount =
? latestRooms.length latestRooms.length < items.length ? latestRooms.length : items.length;
: items.length;
for (int i = 0; i < replaceCount; i++) { for (int i = 0; i < replaceCount; i++) {
if (!_sameRoom(items[i], latestRooms[i])) { if (!_sameRoom(items[i], latestRooms[i])) {
items[i] = latestRooms[i]; items[i] = latestRooms[i];

View File

@ -25,13 +25,17 @@ class SCRoomHistoryPage extends SCPageList {
class _SCRoomHistoryPageState class _SCRoomHistoryPageState
extends SCPageListState<FollowRoomRes, SCRoomHistoryPage> extends SCPageListState<FollowRoomRes, SCRoomHistoryPage>
with WidgetsBindingObserver { with WidgetsBindingObserver, AutomaticKeepAliveClientMixin {
static const Duration _roomListRefreshInterval = Duration(seconds: 15); static const Duration _roomListRefreshInterval = Duration(seconds: 15);
String? lastId; String? lastId;
Timer? _roomListRefreshTimer; Timer? _roomListRefreshTimer;
bool _isSilentRefreshingRooms = false; bool _isSilentRefreshingRooms = false;
bool _wasTickerModeEnabled = false; bool _wasTickerModeEnabled = false;
bool _hasCompletedInitialLoad = false;
@override
bool get wantKeepAlive => true;
@override @override
void initState() { void initState() {
@ -71,11 +75,12 @@ class _SCRoomHistoryPageState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context);
return Scaffold( return Scaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
body: body:
items.isEmpty && isLoading !_hasCompletedInitialLoad && items.isEmpty && isLoading
? const SCHomeSkeletonShimmer( ? const SCHomeSkeletonShimmer(
builder: _buildHistorySkeletonContent, builder: _buildHistorySkeletonContent,
) )
@ -322,8 +327,14 @@ class _SCRoomHistoryPageState
if (roomList.isNotEmpty) { if (roomList.isNotEmpty) {
lastId = roomList.last.id; lastId = roomList.last.id;
} }
if (page == 1) {
_hasCompletedInitialLoad = true;
}
onSuccess(roomList); onSuccess(roomList);
} catch (e) { } catch (e) {
if (page == 1) {
_hasCompletedInitialLoad = true;
}
if (onErr != null) { if (onErr != null) {
onErr(); onErr();
} }
@ -380,9 +391,8 @@ class _SCRoomHistoryPageState
} }
bool changed = false; bool changed = false;
final replaceCount = latestRooms.length < items.length final replaceCount =
? latestRooms.length latestRooms.length < items.length ? latestRooms.length : items.length;
: items.length;
for (int i = 0; i < replaceCount; i++) { for (int i = 0; i < replaceCount; i++) {
if (!_sameRoom(items[i], latestRooms[i])) { if (!_sameRoom(items[i], latestRooms[i])) {
items[i] = latestRooms[i]; items[i] = latestRooms[i];

View File

@ -22,11 +22,14 @@ class SCHomeMinePage extends StatefulWidget {
} }
class _HomeMinePageState extends State<SCHomeMinePage> class _HomeMinePageState extends State<SCHomeMinePage>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin {
late TabController _tabController; late TabController _tabController;
final List<Widget> _pages = const [SCRoomHistoryPage(), SCRoomFollowPage()]; final List<Widget> _pages = const [SCRoomHistoryPage(), SCRoomFollowPage()];
final List<Widget> _tabs = []; final List<Widget> _tabs = [];
@override
bool get wantKeepAlive => true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -45,6 +48,7 @@ class _HomeMinePageState extends State<SCHomeMinePage>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context);
_tabs.clear(); _tabs.clear();
_tabs.add(Tab(text: SCAppLocalizations.of(context)!.recent)); _tabs.add(Tab(text: SCAppLocalizations.of(context)!.recent));
_tabs.add(Tab(text: SCAppLocalizations.of(context)!.followed)); _tabs.add(Tab(text: SCAppLocalizations.of(context)!.followed));

View File

@ -36,7 +36,10 @@ class SCHomePartyPage extends StatefulWidget {
} }
class _HomePartyPageState extends State<SCHomePartyPage> class _HomePartyPageState extends State<SCHomePartyPage>
with SingleTickerProviderStateMixin, WidgetsBindingObserver { with
SingleTickerProviderStateMixin,
WidgetsBindingObserver,
AutomaticKeepAliveClientMixin {
static const Duration _roomListRefreshInterval = Duration(seconds: 15); static const Duration _roomListRefreshInterval = Duration(seconds: 15);
static const Duration _roomCounterHydrationMinGap = Duration(seconds: 20); static const Duration _roomCounterHydrationMinGap = Duration(seconds: 20);
static const int _roomCounterHydrationLimit = 6; static const int _roomCounterHydrationLimit = 6;
@ -61,8 +64,12 @@ class _HomePartyPageState extends State<SCHomePartyPage>
bool _isSilentRefreshingRooms = false; bool _isSilentRefreshingRooms = false;
bool _isHydratingVisibleRoomCounters = false; bool _isHydratingVisibleRoomCounters = false;
bool _wasTickerModeEnabled = false; bool _wasTickerModeEnabled = false;
bool _hasCompletedInitialRoomLoad = false;
DateTime? _lastRoomCounterHydrationAt; DateTime? _lastRoomCounterHydrationAt;
@override
bool get wantKeepAlive => true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -101,9 +108,10 @@ class _HomePartyPageState extends State<SCHomePartyPage>
_wasTickerModeEnabled = isTickerModeEnabled; _wasTickerModeEnabled = isTickerModeEnabled;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( super.build(context);
return Scaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
body: Stack( body: Stack(
@ -601,10 +609,10 @@ class _HomePartyPageState extends State<SCHomePartyPage>
); );
} }
Widget _buildRoomsSection() { Widget _buildRoomsSection() {
if (isLoading && rooms.isEmpty) { if (!_hasCompletedInitialRoomLoad && isLoading && rooms.isEmpty) {
return _buildRoomGridSkeleton(); return _buildRoomGridSkeleton();
} }
if (rooms.isEmpty) { if (rooms.isEmpty) {
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
@ -977,6 +985,7 @@ class _HomePartyPageState extends State<SCHomePartyPage>
.then((values) { .then((values) {
rooms = _mergeLatestRoomsWithCurrentCounters(values); rooms = _mergeLatestRoomsWithCurrentCounters(values);
isLoading = false; isLoading = false;
_hasCompletedInitialRoomLoad = true;
_refreshController.refreshCompleted(); _refreshController.refreshCompleted();
_refreshController.loadComplete(); _refreshController.loadComplete();
if (mounted) { if (mounted) {
@ -986,10 +995,11 @@ class _HomePartyPageState extends State<SCHomePartyPage>
}) })
.catchError((e) { .catchError((e) {
_refreshController.loadNoData(); _refreshController.loadNoData();
_refreshController.refreshCompleted(); _refreshController.refreshCompleted();
isLoading = false; isLoading = false;
if (mounted) setState(() {}); _hasCompletedInitialRoomLoad = true;
}); if (mounted) setState(() {});
});
generalManager.loadMainBanner().whenComplete(() { generalManager.loadMainBanner().whenComplete(() {
if (!mounted) { if (!mounted) {
return; return;

View File

@ -39,6 +39,7 @@ class _SCIndexPageState extends State<SCIndexPage> {
int _currentIndex = 0; int _currentIndex = 0;
final List<Widget> _pages = []; final List<Widget> _pages = [];
final Set<int> _builtPageIndexes = <int>{0};
final List<BottomNavigationBarItem> _bottomItems = []; final List<BottomNavigationBarItem> _bottomItems = [];
SCAppGeneralManager? generalProvider; SCAppGeneralManager? generalProvider;
Locale? _lastLocale; Locale? _lastLocale;
@ -130,7 +131,7 @@ class _SCIndexPageState extends State<SCIndexPage> {
child: Scaffold( child: Scaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
body: _pages[_currentIndex], body: _buildCachedBody(),
bottomNavigationBar: Container( bottomNavigationBar: Container(
height: 85.w, height: 85.w,
decoration: BoxDecoration( decoration: BoxDecoration(
@ -167,7 +168,7 @@ class _SCIndexPageState extends State<SCIndexPage> {
showSelectedLabels: true, showSelectedLabels: true,
items: _bottomItems, items: _bottomItems,
currentIndex: _currentIndex, currentIndex: _currentIndex,
onTap: (index) => setState(() => _currentIndex = index), onTap: _switchBottomTab,
), ),
), ),
), ),
@ -205,6 +206,31 @@ class _SCIndexPageState extends State<SCIndexPage> {
_pages.add(MePage2()); _pages.add(MePage2());
} }
Widget _buildCachedBody() {
return IndexedStack(
index: _currentIndex,
children: List<Widget>.generate(_pages.length, (index) {
if (!_builtPageIndexes.contains(index)) {
return const SizedBox.shrink();
}
return TickerMode(
enabled: index == _currentIndex,
child: _pages[index],
);
}),
);
}
void _switchBottomTab(int index) {
if (index == _currentIndex) {
return;
}
setState(() {
_currentIndex = index;
_builtPageIndexes.add(index);
});
}
void _rebuildBottomItemsIfNeeded() { void _rebuildBottomItemsIfNeeded() {
final locale = Localizations.localeOf(context); final locale = Localizations.localeOf(context);
if (_lastLocale == locale && _bottomItems.isNotEmpty) { if (_lastLocale == locale && _bottomItems.isNotEmpty) {

View File

@ -40,11 +40,9 @@ class RoomMusicFolderPage extends StatelessWidget {
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () async { onTap: () async {
final changed = await Navigator.of(context).push<bool>( final changed = await Navigator.of(
MaterialPageRoute( context,
builder: (_) => RoomMusicSelectPage(folder: folder), ).push<bool>(_musicRoute(RoomMusicSelectPage(folder: folder)));
),
);
if (changed == true && context.mounted) { if (changed == true && context.mounted) {
Navigator.pop(context, true); Navigator.pop(context, true);
} }
@ -124,3 +122,22 @@ class RoomMusicFolderPage extends StatelessWidget {
); );
} }
} }
Route<T> _musicRoute<T>(Widget page) {
return PageRouteBuilder<T>(
opaque: true,
barrierColor: const Color(0xFF071F1B),
transitionDuration: const Duration(milliseconds: 180),
reverseTransitionDuration: const Duration(milliseconds: 160),
pageBuilder:
(context, animation, secondaryAnimation) =>
ColoredBox(color: const Color(0xFF071F1B), child: page),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final tween = Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).chain(CurveTween(curve: Curves.easeOutCubic));
return SlideTransition(position: animation.drive(tween), child: child);
},
);
}

View File

@ -304,7 +304,16 @@ class _RoomMusicPageState extends State<RoomMusicPage> {
final bottomPadding = manager.current == null ? 20.w : 190.w; final bottomPadding = manager.current == null ? 20.w : 190.w;
return ReorderableListView.builder( return ReorderableListView.builder(
padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, bottomPadding), padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, bottomPadding),
buildDefaultDragHandles: false,
itemCount: songs.length, itemCount: songs.length,
proxyDecorator: (child, index, animation) {
return Material(
color: Colors.transparent,
shadowColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
child: child,
);
},
onReorder: (oldIndex, newIndex) { onReorder: (oldIndex, newIndex) {
manager.reorder(oldIndex, newIndex); manager.reorder(oldIndex, newIndex);
}, },
@ -355,9 +364,9 @@ class _RoomMusicPageState extends State<RoomMusicPage> {
SCTts.show(RoomMusicTexts.t(context, "roomMusicNotFound", "未找到音乐")); SCTts.show(RoomMusicTexts.t(context, "roomMusicNotFound", "未找到音乐"));
return; return;
} }
final changed = await Navigator.of(context).push<bool>( final changed = await Navigator.of(
MaterialPageRoute(builder: (_) => RoomMusicFolderPage(folders: folders)), context,
); ).push<bool>(_musicRoute(RoomMusicFolderPage(folders: folders)));
if (changed == true && mounted) { if (changed == true && mounted) {
await manager.reload(); await manager.reload();
} }
@ -524,61 +533,85 @@ class _SwipeDeleteTileState extends State<_SwipeDeleteTile> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final actionWidth = 60.w; final actionWidth = 60.w;
return SizedBox( return LayoutBuilder(
height: 56.w, builder: (context, constraints) {
child: Stack( return ClipRect(
children: [ child: SizedBox(
PositionedDirectional( height: 56.w,
end: 0,
top: 0,
bottom: 0,
width: actionWidth,
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: widget.onDelete, onHorizontalDragUpdate: (details) {
child: Container( setState(() {
decoration: BoxDecoration( _dragOffset = (_dragOffset + details.delta.dx).clamp(
color: const Color(0xFFE64A4A), -actionWidth,
borderRadius: BorderRadius.circular(8.w), 0,
), );
alignment: Alignment.center, });
child: Image.asset( },
"sc_images/room/sc_music_material_delete.png", onHorizontalDragEnd: (_) {
width: 22.w, setState(() {
height: 22.w, _dragOffset =
_dragOffset.abs() > actionWidth * 0.38 ? -actionWidth : 0;
});
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
transform: Matrix4.translationValues(_dragOffset, 0, 0),
child: Row(
children: [
SizedBox(width: constraints.maxWidth, child: widget.child),
SizedBox(
width: actionWidth,
height: 56.w,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onDelete,
child: Container(
margin: EdgeInsetsDirectional.only(start: 4.w),
decoration: BoxDecoration(
color: const Color(0xFFE64A4A),
borderRadius: BorderRadius.circular(8.w),
),
alignment: Alignment.center,
child: Image.asset(
"sc_images/room/sc_music_material_delete.png",
width: 22.w,
height: 22.w,
),
),
),
),
],
), ),
), ),
), ),
), ),
GestureDetector( );
behavior: HitTestBehavior.opaque, },
onHorizontalDragUpdate: (details) {
setState(() {
_dragOffset = (_dragOffset + details.delta.dx).clamp(
-actionWidth,
0,
);
});
},
onHorizontalDragEnd: (_) {
setState(() {
_dragOffset =
_dragOffset.abs() > actionWidth * 0.38 ? -actionWidth : 0;
});
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
transform: Matrix4.translationValues(_dragOffset, 0, 0),
child: widget.child,
),
),
],
),
); );
} }
} }
Route<T> _musicRoute<T>(Widget page) {
return PageRouteBuilder<T>(
opaque: true,
barrierColor: const Color(0xFF071F1B),
transitionDuration: const Duration(milliseconds: 180),
reverseTransitionDuration: const Duration(milliseconds: 160),
pageBuilder:
(context, animation, secondaryAnimation) =>
ColoredBox(color: const Color(0xFF071F1B), child: page),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final tween = Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).chain(CurveTween(curve: Curves.easeOutCubic));
return SlideTransition(position: animation.drive(tween), child: child);
},
);
}
class _PlainIconButton extends StatelessWidget { class _PlainIconButton extends StatelessWidget {
const _PlainIconButton({required this.child, required this.onPressed}); const _PlainIconButton({required this.child, required this.onPressed});

View File

@ -28,6 +28,26 @@ class VoiceRoomRoute implements SCIRouterProvider {
return MaterialPageRoute<T>(builder: (_) => page, settings: settings); return MaterialPageRoute<T>(builder: (_) => page, settings: settings);
} }
static Route<T> _buildDarkRoute<T>(Widget page, {RouteSettings? settings}) {
return PageRouteBuilder<T>(
settings: settings,
opaque: true,
barrierColor: const Color(0xFF071F1B),
transitionDuration: const Duration(milliseconds: 180),
reverseTransitionDuration: const Duration(milliseconds: 160),
pageBuilder:
(context, animation, secondaryAnimation) =>
ColoredBox(color: const Color(0xFF071F1B), child: page),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final tween = Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).chain(CurveTween(curve: Curves.easeOutCubic));
return SlideTransition(position: animation.drive(tween), child: child);
},
);
}
static Future<T?> openVoiceRoom<T>( static Future<T?> openVoiceRoom<T>(
BuildContext context, { BuildContext context, {
bool replace = false, bool replace = false,
@ -102,10 +122,12 @@ class VoiceRoomRoute implements SCIRouterProvider {
BuildContext context, { BuildContext context, {
bool rootNavigator = false, bool rootNavigator = false,
}) { }) {
return Navigator.of( return Navigator.of(context, rootNavigator: rootNavigator).push<T>(
context, _buildDarkRoute<T>(
rootNavigator: rootNavigator, const RoomMusicPage(),
).push<T>(_buildRoute<T>(const RoomMusicPage())); settings: RouteSettings(name: roomMusic),
),
);
} }
@override @override

View File

@ -1,8 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/app/constants/sc_global_config.dart'; import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/modules/user/vip/vip_route.dart'; import 'package:yumi/modules/user/vip/vip_route.dart';
import 'package:yumi/services/auth/user_profile_manager.dart';
import 'package:yumi/shared/business_logic/models/res/sc_vip_res.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_vip_repository_imp.dart';
import 'package:yumi/shared/tools/sc_loading_manager.dart';
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
class VipDetailPage extends StatefulWidget { class VipDetailPage extends StatefulWidget {
const VipDetailPage({super.key}); const VipDetailPage({super.key});
@ -12,13 +21,166 @@ class VipDetailPage extends StatefulWidget {
} }
class _VipDetailPageState extends State<VipDetailPage> { class _VipDetailPageState extends State<VipDetailPage> {
final SCVipRepositoryImp _repository = SCVipRepositoryImp();
int _selectedLevel = 1; int _selectedLevel = 1;
late bool _showUpdateButton; bool _isLoading = false;
bool _isPurchasing = false;
int? _previewingLevel;
Object? _loadError;
SCVipHomeRes? _home;
SCVipStatusRes? _status;
SCVipPreviewRes? _preview;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_showUpdateButton = false; _loadVipData();
}
Future<void> _loadVipData({bool showLoading = true}) async {
if (showLoading) {
setState(() {
_isLoading = true;
_loadError = null;
});
}
try {
final results = await Future.wait<dynamic>([
_repository.vipHome(),
_repository.vipStatus().catchError((error) {
debugPrint('VIP status load failed: $error');
return SCVipStatusRes();
}),
]);
final home = results[0] as SCVipHomeRes;
final status = results[1] as SCVipStatusRes;
final resolvedStatus = status.levelInt > 0 ? status : home.state;
final selectedLevel = _resolveInitialLevel(home, resolvedStatus);
if (!mounted) return;
setState(() {
_home = home;
_status = resolvedStatus;
_selectedLevel = selectedLevel;
_preview = null;
_previewingLevel = null;
_isLoading = false;
_loadError = null;
});
_loadPreviewForLevel(selectedLevel);
} catch (error) {
if (!mounted) return;
setState(() {
_loadError = error;
_isLoading = false;
});
}
}
int _resolveInitialLevel(SCVipHomeRes home, SCVipStatusRes? status) {
if (status?.isActive == true && status!.levelInt > 0) {
return status.levelInt.clamp(1, 5).toInt();
}
for (final level in home.levels) {
if (level.enabled != false && level.canPurchase == true) {
return level.levelInt.clamp(1, 5).toInt();
}
}
for (final level in home.levels) {
if (level.enabled != false && level.levelInt > 0) {
return level.levelInt.clamp(1, 5).toInt();
}
}
return 1;
}
SCVipLevelConfigRes? _levelConfigFor(int level) {
final levels = _home?.levels ?? <SCVipLevelConfigRes>[];
for (final config in levels) {
if (config.levelInt == level) {
return config;
}
}
return null;
}
void _selectLevel(int level) {
setState(() {
_selectedLevel = level;
_preview = null;
});
_loadPreviewForLevel(level);
}
Future<void> _loadPreviewForLevel(int level) async {
final config = _levelConfigFor(level);
if (_home?.configured == false ||
config?.enabled == false ||
config?.canPurchase != true) {
return;
}
setState(() => _previewingLevel = level);
try {
final preview = await _repository.vipPreview(targetLevel: level);
if (!mounted || _selectedLevel != level) return;
setState(() {
_preview = preview;
_previewingLevel = null;
});
} catch (_) {
if (!mounted || _selectedLevel != level) return;
setState(() => _previewingLevel = null);
}
}
Future<void> _purchaseSelectedLevel() async {
if (!_canPurchaseSelected || _isPurchasing) {
return;
}
final profileManager = Provider.of<SocialChatUserProfileManager>(
context,
listen: false,
);
setState(() => _isPurchasing = true);
SCLoadingManager.show();
try {
if (_preview?.targetLevelInt != _selectedLevel) {
_preview = await _repository.vipPreview(targetLevel: _selectedLevel);
}
if (!mounted) return;
final purchase = await _repository.vipPurchase(
targetLevel: _selectedLevel,
bizIdempotentKey: _buildBizIdempotentKey(),
);
if (!mounted) return;
if (purchase.success == true) {
SCTts.show(SCAppLocalizations.of(context)!.purchaseIsSuccessful);
profileManager.fetchUserProfileData(loadGuardCount: false);
profileManager.balance();
await _loadVipData(showLoading: false);
}
} catch (error) {
debugPrint('VIP purchase failed: $error');
} finally {
SCLoadingManager.hide();
if (mounted) {
setState(() => _isPurchasing = false);
}
}
}
String _buildBizIdempotentKey() {
final userId = AccountStorage().getCurrentUser()?.userProfile?.id ?? '0';
return 'vip-buy-$userId-${DateTime.now().millisecondsSinceEpoch}';
} }
@override @override
@ -78,28 +240,7 @@ class _VipDetailPageState extends State<VipDetailPage> {
children: [ children: [
_buildTopBar(), _buildTopBar(),
_buildLevelTabs(), _buildLevelTabs(),
Expanded( Expanded(child: _buildBodyContent()),
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(), _buildActivateBar(),
], ],
), ),
@ -109,6 +250,74 @@ class _VipDetailPageState extends State<VipDetailPage> {
); );
} }
Widget _buildBodyContent() {
if (_isLoading && _home == null) {
return Center(
child: CircularProgressIndicator(
color: const Color(0xFFEBCB76),
strokeWidth: 2.w,
),
);
}
if (_loadError != null && _home == null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Failed to load VIP data',
style: TextStyle(color: Colors.white, fontSize: 14.sp),
),
SizedBox(height: 12.w),
SCDebounceWidget(
onTap: () => _loadVipData(),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 18.w, vertical: 8.w),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18.w),
border: Border.all(
color: const Color(0xFFEBCB76),
width: 1.w,
),
),
child: Text(
'Retry',
style: TextStyle(
color: const Color(0xFFFFE8A7),
fontSize: 13.sp,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
);
}
return 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),
],
),
);
}
Widget _buildTopBar() { Widget _buildTopBar() {
return SizedBox( return SizedBox(
height: 44.w, height: 44.w,
@ -172,21 +381,25 @@ class _VipDetailPageState extends State<VipDetailPage> {
separatorBuilder: (_, __) => SizedBox(width: 30.w), separatorBuilder: (_, __) => SizedBox(width: 30.w),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final level = index + 1; final level = index + 1;
final config = _levelConfigFor(level);
final enabled = config?.enabled != false;
final selected = level == _selectedLevel; final selected = level == _selectedLevel;
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _selectedLevel = level), onTap: () => _selectLevel(level),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
SizedBox(height: 4.w), SizedBox(height: 4.w),
Text( Text(
'VIP$level', config?.displayNameText ?? 'VIP$level',
style: TextStyle( style: TextStyle(
color: color:
selected selected
? Colors.white ? Colors.white
: Colors.white.withValues(alpha: 0.42), : Colors.white.withValues(
alpha: enabled ? 0.42 : 0.20,
),
fontSize: 15.sp, fontSize: 15.sp,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@ -252,12 +465,19 @@ class _VipDetailPageState extends State<VipDetailPage> {
PositionedDirectional( PositionedDirectional(
start: 22.w, start: 22.w,
top: 80.w, top: 80.w,
child: Text( end: 160.w,
'Not activated', child: SizedBox(
style: TextStyle( height: 32.w,
color: const Color(0xFFF2C766).withValues(alpha: 0.78), child: Text(
fontSize: 12.sp, _selectedLevelStatusText,
fontWeight: FontWeight.w500, maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: const Color(0xFFF2C766).withValues(alpha: 0.78),
fontSize: 12.sp,
fontWeight: FontWeight.w500,
height: 1.2,
),
), ),
), ),
), ),
@ -539,6 +759,10 @@ class _VipDetailPageState extends State<VipDetailPage> {
final bottomInset = MediaQuery.of(context).padding.bottom; final bottomInset = MediaQuery.of(context).padding.bottom;
final actionButton = _actionButtonConfigForLevel(_assetLevel); final actionButton = _actionButtonConfigForLevel(_assetLevel);
final barOverflow = 20.w + bottomInset; final barOverflow = 20.w + bottomInset;
final canPurchase =
_canPurchaseSelected &&
!_isPurchasing &&
_previewingLevel != _selectedLevel;
return SizedBox( return SizedBox(
height: 106.5.w, height: 106.5.w,
width: double.infinity, width: double.infinity,
@ -588,7 +812,7 @@ class _VipDetailPageState extends State<VipDetailPage> {
), ),
SizedBox(width: 6.w), SizedBox(width: 6.w),
Text( Text(
'30.5 M / 30 Days', _priceText,
style: TextStyle( style: TextStyle(
color: const Color(0xFFFFE8A7), color: const Color(0xFFFFE8A7),
fontSize: 16.sp, fontSize: 16.sp,
@ -596,26 +820,42 @@ class _VipDetailPageState extends State<VipDetailPage> {
), ),
), ),
const Spacer(), const Spacer(),
Container( SCDebounceWidget(
width: 106.w, onTap: canPurchase ? _purchaseSelectedLevel : () {},
height: 42.w, child: Opacity(
alignment: Alignment.center, opacity: canPurchase ? 1 : 0.52,
decoration: BoxDecoration( child: Container(
image: DecorationImage( width: 106.w,
image: AssetImage(actionButton.backgroundAssetPath), height: 42.w,
fit: BoxFit.fill, alignment: Alignment.center,
), decoration: BoxDecoration(
), image: DecorationImage(
child: Text( image: AssetImage(actionButton.backgroundAssetPath),
actionButton.label, fit: BoxFit.fill,
textAlign: TextAlign.center, ),
style: TextStyle( ),
color: actionButton.textColor, child:
fontFamily: actionButton.fontFamily, _isPurchasing || _previewingLevel == _selectedLevel
fontSize: actionButton.fontSize.sp, ? SizedBox(
fontStyle: FontStyle.normal, width: 16.w,
fontWeight: FontWeight.w700, height: 16.w,
height: 1, child: CircularProgressIndicator(
strokeWidth: 2.w,
color: actionButton.textColor,
),
)
: 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,
),
),
), ),
), ),
), ),
@ -713,17 +953,105 @@ class _VipDetailPageState extends State<VipDetailPage> {
} }
_VipActionButtonConfig _actionButtonConfigForLevel(int level) { _VipActionButtonConfig _actionButtonConfigForLevel(int level) {
switch (level) { final config = _levelConfigFor(level);
case 1: if (_home?.configured == false || config?.enabled == false) {
case 2: return const _VipActionButtonConfig.disabled(label: 'Unavailable');
case 3:
case 4:
case 5:
default:
return _showUpdateButton
? const _VipActionButtonConfig.update()
: const _VipActionButtonConfig.activate();
} }
final activeLevel = _activeVipLevel;
if (_status?.isActive == true) {
if (level < activeLevel) {
return const _VipActionButtonConfig.disabled(label: 'Activated');
}
if (level == activeLevel) {
return config?.canPurchase == true
? const _VipActionButtonConfig.renew()
: const _VipActionButtonConfig.disabled(label: 'Activated');
}
return config?.canPurchase == true
? const _VipActionButtonConfig.update()
: const _VipActionButtonConfig.disabled(label: 'Unavailable');
}
return config?.canPurchase == true
? const _VipActionButtonConfig.activate()
: const _VipActionButtonConfig.disabled(label: 'Unavailable');
}
bool get _canPurchaseSelected {
final config = _levelConfigFor(_selectedLevel);
return _home?.configured != false &&
config?.enabled != false &&
config?.canPurchase == true;
}
int get _activeVipLevel {
final status = _status;
if (status?.isActive == true) {
return status!.levelInt.clamp(1, 5).toInt();
}
return 0;
}
String get _selectedLevelStatusText {
if (_home?.configured == false) {
return 'Not configured';
}
final config = _levelConfigFor(_selectedLevel);
if (config?.enabled == false) {
return 'Unavailable';
}
final activeLevel = _activeVipLevel;
if (activeLevel > 0) {
if (_selectedLevel == activeLevel) {
final expireAt = _shortDate(_status?.expireAt);
return expireAt.isEmpty ? 'Activated' : 'Active until $expireAt';
}
if (_selectedLevel < activeLevel) {
return 'VIP$activeLevel activated';
}
return 'Upgrade from VIP$activeLevel';
}
return 'Not activated';
}
String get _priceText {
final config = _levelConfigFor(_selectedLevel);
final preview =
_preview?.targetLevelInt == _selectedLevel ? _preview : null;
final amount =
preview?.payableGold ?? config?.payableGold ?? config?.priceGold;
final days = (preview?.durationDays ?? config?.durationDays ?? 30).toInt();
final amountText = amount == null ? '--' : _formatGold(amount);
return '$amountText / $days Days';
}
String _formatGold(num value) {
final doubleValue = value.toDouble();
if (doubleValue >= 1000000) {
return '${_trimNumber(doubleValue / 1000000)} M';
}
if (doubleValue >= 1000) {
return '${_trimNumber(doubleValue / 1000)} K';
}
return _trimNumber(doubleValue);
}
String _trimNumber(double value) {
final fixed =
value >= 10 ? value.toStringAsFixed(0) : value.toStringAsFixed(1);
return fixed.endsWith('.0') ? fixed.substring(0, fixed.length - 2) : fixed;
}
String _shortDate(String? value) {
final date = value?.trim() ?? '';
if (date.length >= 10) {
return date.substring(0, 10);
}
return date;
} }
int get _assetLevel => _selectedLevel.clamp(1, 5).toInt(); int get _assetLevel => _selectedLevel.clamp(1, 5).toInt();
@ -778,6 +1106,24 @@ class _VipActionButtonConfig {
fontFamily: 'Source Han Sans SC', fontFamily: 'Source Han Sans SC',
); );
const _VipActionButtonConfig.renew()
: this(
label: 'Renew',
backgroundAssetPath: 'sc_images/vip/sc_vip_activate_button_bg.png',
textColor: const Color(0xFF7B4B16),
fontSize: 14,
fontFamily: null,
);
const _VipActionButtonConfig.disabled({required String label})
: this(
label: label,
backgroundAssetPath: 'sc_images/vip/sc_vip_activate_button_bg.png',
textColor: const Color(0xFF7B4B16),
fontSize: 13,
fontFamily: null,
);
final String label; final String label;
final String backgroundAssetPath; final String backgroundAssetPath;
final Color textColor; final Color textColor;

View File

@ -97,6 +97,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
RoomStartupStatus _roomStartupStatus = RoomStartupStatus.idle; RoomStartupStatus _roomStartupStatus = RoomStartupStatus.idle;
RoomStartupFailureType _roomStartupFailureType = RoomStartupFailureType.none; RoomStartupFailureType _roomStartupFailureType = RoomStartupFailureType.none;
int _roomStartupFailureToken = 0; int _roomStartupFailureToken = 0;
int _roomEntryRequestSerial = 0;
bool _isHandlingRoomStartupFailure = false; bool _isHandlingRoomStartupFailure = false;
Timer? _micListPollingTimer; Timer? _micListPollingTimer;
Timer? _onlineUsersPollingTimer; Timer? _onlineUsersPollingTimer;
@ -1635,29 +1636,80 @@ class RealTimeCommunicationManager extends ChangeNotifier {
return; return;
} }
rtmProvider = Provider.of<RtmProvider>(context, listen: false); rtmProvider = Provider.of<RtmProvider>(context, listen: false);
try { _setRoomStartupLoading();
currenRoom = await SCAccountRepository().entryRoom( setRoomVisualEffectsEnabled(true);
VoiceRoomRoute.openVoiceRoom(context);
final entryRequestSerial = ++_roomEntryRequestSerial;
unawaited(
_enterAndInitializeVoiceRoomSession(
roomId, roomId,
entryRequestSerial: entryRequestSerial,
pwd: pwd, pwd: pwd,
redPackId: redPackId,
needOpenRedenvelope: needOpenRedenvelope,
);
await initializeRoomSession(
needOpenRedenvelope: needOpenRedenvelope, needOpenRedenvelope: needOpenRedenvelope,
redPackId: redPackId, redPackId: redPackId,
); ),
notifyListeners(); );
} catch (e) {
await resetLocalRoomState(fallbackRtmProvider: rtmProvider);
rethrow;
}
} }
} }
Future<void> _enterAndInitializeVoiceRoomSession(
String roomId, {
required int entryRequestSerial,
String? pwd,
bool needOpenRedenvelope = false,
String redPackId = "",
}) async {
try {
final enteredRoom = await SCAccountRepository().entryRoom(
roomId,
pwd: pwd,
redPackId: redPackId,
needOpenRedenvelope: needOpenRedenvelope,
);
if (!_isActiveRoomEntryRequest(entryRequestSerial)) {
unawaited(_cleanupStaleEnteredRoom(enteredRoom));
return;
}
currenRoom = enteredRoom;
notifyListeners();
await initializeRoomSession(
needOpenRedenvelope: needOpenRedenvelope,
redPackId: redPackId,
shouldOpenRoomPage: false,
);
} catch (e, stackTrace) {
if (!_isActiveRoomEntryRequest(entryRequestSerial)) {
return;
}
debugPrint('[RoomStartup] entry room failed: $e\n$stackTrace');
_setRoomStartupFailed(RoomStartupFailureType.entry);
}
}
bool _isActiveRoomEntryRequest(int entryRequestSerial) {
return entryRequestSerial == _roomEntryRequestSerial &&
_roomStartupStatus == RoomStartupStatus.loading;
}
Future<void> _cleanupStaleEnteredRoom(JoinRoomRes enteredRoom) async {
final staleRoomId = enteredRoom.roomProfile?.roomProfile?.id ?? "";
if (staleRoomId.isEmpty) {
return;
}
await _retryExitNetworkRequest('quit stale entered room $staleRoomId', () {
return SCAccountRepository().quitRoom(staleRoomId).then((didQuit) {
if (!didQuit) {
throw StateError('quitRoom returned false');
}
});
});
}
/// ///
Future<void> initializeRoomSession({ Future<void> initializeRoomSession({
bool needOpenRedenvelope = false, bool needOpenRedenvelope = false,
String? redPackId, String? redPackId,
bool shouldOpenRoomPage = true,
}) async { }) async {
_disableMicListRefreshForCurrentSession = false; _disableMicListRefreshForCurrentSession = false;
_disableOnlineUsersRefreshForCurrentSession = false; _disableOnlineUsersRefreshForCurrentSession = false;
@ -1684,7 +1736,9 @@ class RealTimeCommunicationManager extends ChangeNotifier {
} }
_setRoomStartupLoading(); _setRoomStartupLoading();
setRoomVisualEffectsEnabled(true); setRoomVisualEffectsEnabled(true);
VoiceRoomRoute.openVoiceRoom(context!); if (shouldOpenRoomPage) {
VoiceRoomRoute.openVoiceRoom(context!);
}
unawaited(_bootstrapEnteredVoiceRoomSession()); unawaited(_bootstrapEnteredVoiceRoomSession());
} }
@ -2119,6 +2173,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
/// ///
void _clearData() { void _clearData() {
_roomEntryRequestSerial += 1;
_stopRoomStatePolling(); _stopRoomStatePolling();
_resetHeartbeatTracking(); _resetHeartbeatTracking();
_resetAgoraTracking(); _resetAgoraTracking();

View File

@ -505,7 +505,13 @@ class SocialChatUserProfile {
} }
PropsResources? getVIP() { PropsResources? getVIP() {
return null; PropsResources? pr;
_useProps?.forEach((value) {
if (value.propsResources?.type == SCPropsType.NOBLE_VIP.name) {
pr = value.propsResources;
}
});
return pr;
} }
PropsResources? getDataCard() { PropsResources? getDataCard() {

View File

@ -0,0 +1,662 @@
class SCVipResourceRes {
SCVipResourceRes({String? resourceId, String? name, String? url}) {
_resourceId = resourceId;
_name = name;
_url = url;
}
SCVipResourceRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_resourceId = _stringValue(map['resourceId']);
_name = _stringValue(map['name']);
_url = _stringValue(map['url']);
}
String? _resourceId;
String? _name;
String? _url;
String? get resourceId => _resourceId;
String? get name => _name;
String? get url => _url;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['resourceId'] = _resourceId;
map['name'] = _name;
map['url'] = _url;
return map;
}
}
class SCVipStatusRes {
SCVipStatusRes({
String? userId,
String? sysOrigin,
bool? active,
String? status,
num? level,
String? levelCode,
String? displayName,
String? startAt,
String? expireAt,
SCVipResourceRes? badge,
SCVipResourceRes? avatarFrame,
SCVipResourceRes? entryEffect,
SCVipResourceRes? chatBubble,
SCVipResourceRes? floatPicture,
}) {
_userId = userId;
_sysOrigin = sysOrigin;
_active = active;
_status = status;
_level = level;
_levelCode = levelCode;
_displayName = displayName;
_startAt = startAt;
_expireAt = expireAt;
_badge = badge;
_avatarFrame = avatarFrame;
_entryEffect = entryEffect;
_chatBubble = chatBubble;
_floatPicture = floatPicture;
}
SCVipStatusRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_userId = _stringValue(map['userId']);
_sysOrigin = _stringValue(map['sysOrigin']);
_active = _boolValue(map['active']);
_status = _stringValue(map['status']);
_level = _numValue(map['level']);
_levelCode = _stringValue(map['levelCode']);
_displayName = _stringValue(map['displayName']);
_startAt = _stringValue(map['startAt']);
_expireAt = _stringValue(map['expireAt']);
_badge = _resourceValue(map['badge']);
_avatarFrame = _resourceValue(map['avatarFrame']);
_entryEffect = _resourceValue(map['entryEffect']);
_chatBubble = _resourceValue(map['chatBubble']);
_floatPicture = _resourceValue(map['floatPicture']);
}
String? _userId;
String? _sysOrigin;
bool? _active;
String? _status;
num? _level;
String? _levelCode;
String? _displayName;
String? _startAt;
String? _expireAt;
SCVipResourceRes? _badge;
SCVipResourceRes? _avatarFrame;
SCVipResourceRes? _entryEffect;
SCVipResourceRes? _chatBubble;
SCVipResourceRes? _floatPicture;
String? get userId => _userId;
String? get sysOrigin => _sysOrigin;
bool? get active => _active;
String? get status => _status;
num? get level => _level;
String? get levelCode => _levelCode;
String? get displayName => _displayName;
String? get startAt => _startAt;
String? get expireAt => _expireAt;
SCVipResourceRes? get badge => _badge;
SCVipResourceRes? get avatarFrame => _avatarFrame;
SCVipResourceRes? get entryEffect => _entryEffect;
SCVipResourceRes? get chatBubble => _chatBubble;
SCVipResourceRes? get floatPicture => _floatPicture;
bool get isActive => _active == true && (_level ?? 0) > 0;
int get levelInt => (_level ?? 0).toInt();
String get displayNameText {
final name = _displayName?.trim();
if (name != null && name.isNotEmpty) return name;
final code = _levelCode?.trim();
if (code != null && code.isNotEmpty) return code;
return levelInt > 0 ? 'VIP $levelInt' : 'VIP';
}
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['userId'] = _userId;
map['sysOrigin'] = _sysOrigin;
map['active'] = _active;
map['status'] = _status;
map['level'] = _level;
map['levelCode'] = _levelCode;
map['displayName'] = _displayName;
map['startAt'] = _startAt;
map['expireAt'] = _expireAt;
if (_badge != null) map['badge'] = _badge?.toJson();
if (_avatarFrame != null) map['avatarFrame'] = _avatarFrame?.toJson();
if (_entryEffect != null) map['entryEffect'] = _entryEffect?.toJson();
if (_chatBubble != null) map['chatBubble'] = _chatBubble?.toJson();
if (_floatPicture != null) map['floatPicture'] = _floatPicture?.toJson();
return map;
}
}
class SCVipLevelConfigRes {
SCVipLevelConfigRes({
num? level,
String? levelCode,
String? displayName,
bool? enabled,
num? durationDays,
num? priceGold,
SCVipResourceRes? badge,
SCVipResourceRes? avatarFrame,
SCVipResourceRes? entryEffect,
SCVipResourceRes? chatBubble,
SCVipResourceRes? floatPicture,
bool? canPurchase,
num? payableGold,
}) {
_level = level;
_levelCode = levelCode;
_displayName = displayName;
_enabled = enabled;
_durationDays = durationDays;
_priceGold = priceGold;
_badge = badge;
_avatarFrame = avatarFrame;
_entryEffect = entryEffect;
_chatBubble = chatBubble;
_floatPicture = floatPicture;
_canPurchase = canPurchase;
_payableGold = payableGold;
}
SCVipLevelConfigRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_level = _numValue(map['level']);
_levelCode = _stringValue(map['levelCode']);
_displayName = _stringValue(map['displayName']);
_enabled = _boolValue(map['enabled']);
_durationDays = _numValue(map['durationDays']);
_priceGold = _numValue(map['priceGold']);
_badge = _resourceValue(map['badge']);
_avatarFrame = _resourceValue(map['avatarFrame']);
_entryEffect = _resourceValue(map['entryEffect']);
_chatBubble = _resourceValue(map['chatBubble']);
_floatPicture = _resourceValue(map['floatPicture']);
_canPurchase = _boolValue(map['canPurchase']);
_payableGold = _numValue(map['payableGold']);
}
num? _level;
String? _levelCode;
String? _displayName;
bool? _enabled;
num? _durationDays;
num? _priceGold;
SCVipResourceRes? _badge;
SCVipResourceRes? _avatarFrame;
SCVipResourceRes? _entryEffect;
SCVipResourceRes? _chatBubble;
SCVipResourceRes? _floatPicture;
bool? _canPurchase;
num? _payableGold;
num? get level => _level;
String? get levelCode => _levelCode;
String? get displayName => _displayName;
bool? get enabled => _enabled;
num? get durationDays => _durationDays;
num? get priceGold => _priceGold;
SCVipResourceRes? get badge => _badge;
SCVipResourceRes? get avatarFrame => _avatarFrame;
SCVipResourceRes? get entryEffect => _entryEffect;
SCVipResourceRes? get chatBubble => _chatBubble;
SCVipResourceRes? get floatPicture => _floatPicture;
bool? get canPurchase => _canPurchase;
num? get payableGold => _payableGold;
int get levelInt => (_level ?? 0).toInt();
String get displayNameText {
final name = _displayName?.trim();
if (name != null && name.isNotEmpty) return name;
final code = _levelCode?.trim();
if (code != null && code.isNotEmpty) return code;
return levelInt > 0 ? 'VIP$levelInt' : 'VIP';
}
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['level'] = _level;
map['levelCode'] = _levelCode;
map['displayName'] = _displayName;
map['enabled'] = _enabled;
map['durationDays'] = _durationDays;
map['priceGold'] = _priceGold;
if (_badge != null) map['badge'] = _badge?.toJson();
if (_avatarFrame != null) map['avatarFrame'] = _avatarFrame?.toJson();
if (_entryEffect != null) map['entryEffect'] = _entryEffect?.toJson();
if (_chatBubble != null) map['chatBubble'] = _chatBubble?.toJson();
if (_floatPicture != null) map['floatPicture'] = _floatPicture?.toJson();
map['canPurchase'] = _canPurchase;
map['payableGold'] = _payableGold;
return map;
}
}
class SCVipHomeRes {
SCVipHomeRes({
String? userId,
String? sysOrigin,
bool? configured,
SCVipStatusRes? state,
List<SCVipLevelConfigRes>? levels,
}) {
_userId = userId;
_sysOrigin = sysOrigin;
_configured = configured;
_state = state;
_levels = levels;
}
SCVipHomeRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_userId = _stringValue(map['userId']);
_sysOrigin = _stringValue(map['sysOrigin']);
_configured = _boolValue(map['configured']);
final stateMap = _asMap(map['state']);
_state =
stateMap != null && stateMap.isNotEmpty
? SCVipStatusRes.fromJson(stateMap)
: null;
_levels =
_asList(
map['levels'],
).map((dynamic item) => SCVipLevelConfigRes.fromJson(item)).toList();
}
String? _userId;
String? _sysOrigin;
bool? _configured;
SCVipStatusRes? _state;
List<SCVipLevelConfigRes>? _levels;
String? get userId => _userId;
String? get sysOrigin => _sysOrigin;
bool? get configured => _configured;
SCVipStatusRes? get state => _state;
List<SCVipLevelConfigRes> get levels => _levels ?? <SCVipLevelConfigRes>[];
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['userId'] = _userId;
map['sysOrigin'] = _sysOrigin;
map['configured'] = _configured;
if (_state != null) map['state'] = _state?.toJson();
map['levels'] = levels.map((item) => item.toJson()).toList();
return map;
}
}
class SCVipPreviewRes {
SCVipPreviewRes({
String? userId,
String? sysOrigin,
String? orderType,
num? currentLevel,
num? targetLevel,
num? durationDays,
num? originalPriceGold,
num? payableGold,
String? startAt,
String? expireAt,
SCVipLevelConfigRes? targetConfig,
}) {
_userId = userId;
_sysOrigin = sysOrigin;
_orderType = orderType;
_currentLevel = currentLevel;
_targetLevel = targetLevel;
_durationDays = durationDays;
_originalPriceGold = originalPriceGold;
_payableGold = payableGold;
_startAt = startAt;
_expireAt = expireAt;
_targetConfig = targetConfig;
}
SCVipPreviewRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_userId = _stringValue(map['userId']);
_sysOrigin = _stringValue(map['sysOrigin']);
_orderType = _stringValue(map['orderType']);
_currentLevel = _numValue(map['currentLevel']);
_targetLevel = _numValue(map['targetLevel']);
_durationDays = _numValue(map['durationDays']);
_originalPriceGold = _numValue(map['originalPriceGold']);
_payableGold = _numValue(map['payableGold']);
_startAt = _stringValue(map['startAt']);
_expireAt = _stringValue(map['expireAt']);
final targetConfigMap = _asMap(map['targetConfig']);
_targetConfig =
targetConfigMap != null && targetConfigMap.isNotEmpty
? SCVipLevelConfigRes.fromJson(targetConfigMap)
: null;
}
String? _userId;
String? _sysOrigin;
String? _orderType;
num? _currentLevel;
num? _targetLevel;
num? _durationDays;
num? _originalPriceGold;
num? _payableGold;
String? _startAt;
String? _expireAt;
SCVipLevelConfigRes? _targetConfig;
String? get userId => _userId;
String? get sysOrigin => _sysOrigin;
String? get orderType => _orderType;
num? get currentLevel => _currentLevel;
num? get targetLevel => _targetLevel;
num? get durationDays => _durationDays;
num? get originalPriceGold => _originalPriceGold;
num? get payableGold => _payableGold;
String? get startAt => _startAt;
String? get expireAt => _expireAt;
SCVipLevelConfigRes? get targetConfig => _targetConfig;
int get targetLevelInt => (_targetLevel ?? 0).toInt();
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['userId'] = _userId;
map['sysOrigin'] = _sysOrigin;
map['orderType'] = _orderType;
map['currentLevel'] = _currentLevel;
map['targetLevel'] = _targetLevel;
map['durationDays'] = _durationDays;
map['originalPriceGold'] = _originalPriceGold;
map['payableGold'] = _payableGold;
map['startAt'] = _startAt;
map['expireAt'] = _expireAt;
if (_targetConfig != null) {
map['targetConfig'] = _targetConfig?.toJson();
}
return map;
}
}
class SCVipPurchaseRes {
SCVipPurchaseRes({
bool? success,
SCVipOrderRes? order,
SCVipStatusRes? state,
}) {
_success = success;
_order = order;
_state = state;
}
SCVipPurchaseRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_success = _boolValue(map['success']);
final orderMap = _asMap(map['order']);
_order =
orderMap != null && orderMap.isNotEmpty
? SCVipOrderRes.fromJson(orderMap)
: null;
final stateMap = _asMap(map['state']);
_state =
stateMap != null && stateMap.isNotEmpty
? SCVipStatusRes.fromJson(stateMap)
: null;
}
bool? _success;
SCVipOrderRes? _order;
SCVipStatusRes? _state;
bool? get success => _success;
SCVipOrderRes? get order => _order;
SCVipStatusRes? get state => _state;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['success'] = _success;
if (_order != null) map['order'] = _order?.toJson();
if (_state != null) map['state'] = _state?.toJson();
return map;
}
}
class SCVipOrdersRes {
SCVipOrdersRes({List<SCVipOrderRes>? records, num? total}) {
_records = records;
_total = total;
}
SCVipOrdersRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_records =
_asList(
map['records'],
).map((dynamic item) => SCVipOrderRes.fromJson(item)).toList();
_total = _numValue(map['total']);
}
List<SCVipOrderRes>? _records;
num? _total;
List<SCVipOrderRes> get records => _records ?? <SCVipOrderRes>[];
num? get total => _total;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['records'] = records.map((item) => item.toJson()).toList();
map['total'] = _total;
return map;
}
}
class SCVipOrderRes {
SCVipOrderRes({
String? id,
String? eventId,
String? sysOrigin,
String? userId,
String? orderType,
num? fromLevel,
num? toLevel,
num? durationDays,
num? originalPriceGold,
num? paidGold,
String? status,
String? startAt,
String? expireAt,
}) {
_id = id;
_eventId = eventId;
_sysOrigin = sysOrigin;
_userId = userId;
_orderType = orderType;
_fromLevel = fromLevel;
_toLevel = toLevel;
_durationDays = durationDays;
_originalPriceGold = originalPriceGold;
_paidGold = paidGold;
_status = status;
_startAt = startAt;
_expireAt = expireAt;
}
SCVipOrderRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_id = _stringValue(map['id']);
_eventId = _stringValue(map['eventId']);
_sysOrigin = _stringValue(map['sysOrigin']);
_userId = _stringValue(map['userId']);
_orderType = _stringValue(map['orderType']);
_fromLevel = _numValue(map['fromLevel']);
_toLevel = _numValue(map['toLevel']);
_durationDays = _numValue(map['durationDays']);
_originalPriceGold = _numValue(map['originalPriceGold']);
_paidGold = _numValue(map['paidGold']);
_status = _stringValue(map['status']);
_startAt = _stringValue(map['startAt']);
_expireAt = _stringValue(map['expireAt']);
}
String? _id;
String? _eventId;
String? _sysOrigin;
String? _userId;
String? _orderType;
num? _fromLevel;
num? _toLevel;
num? _durationDays;
num? _originalPriceGold;
num? _paidGold;
String? _status;
String? _startAt;
String? _expireAt;
String? get id => _id;
String? get eventId => _eventId;
String? get sysOrigin => _sysOrigin;
String? get userId => _userId;
String? get orderType => _orderType;
num? get fromLevel => _fromLevel;
num? get toLevel => _toLevel;
num? get durationDays => _durationDays;
num? get originalPriceGold => _originalPriceGold;
num? get paidGold => _paidGold;
String? get status => _status;
String? get startAt => _startAt;
String? get expireAt => _expireAt;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['id'] = _id;
map['eventId'] = _eventId;
map['sysOrigin'] = _sysOrigin;
map['userId'] = _userId;
map['orderType'] = _orderType;
map['fromLevel'] = _fromLevel;
map['toLevel'] = _toLevel;
map['durationDays'] = _durationDays;
map['originalPriceGold'] = _originalPriceGold;
map['paidGold'] = _paidGold;
map['status'] = _status;
map['startAt'] = _startAt;
map['expireAt'] = _expireAt;
return map;
}
}
Map<String, dynamic>? _asMap(dynamic value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);
return null;
}
List<dynamic> _asList(dynamic value) {
if (value is List) return value;
return <dynamic>[];
}
SCVipResourceRes? _resourceValue(dynamic value) {
final map = _asMap(value);
if (map == null || map.isEmpty) return null;
return SCVipResourceRes.fromJson(map);
}
String? _stringValue(dynamic value) {
if (value == null) return null;
return value.toString();
}
num? _numValue(dynamic value) {
if (value == null) return null;
if (value is num) return value;
return num.tryParse(value.toString());
}
bool? _boolValue(dynamic value) {
if (value == null) return null;
if (value is bool) return value;
if (value is num) return value != 0;
final lower = value.toString().toLowerCase();
if (lower == 'true') return true;
if (lower == 'false') return false;
return null;
}

View File

@ -0,0 +1,19 @@
import 'package:yumi/shared/business_logic/models/res/sc_vip_res.dart';
abstract class SocialChatVipRepository {
Future<SCVipStatusRes> vipStatus();
Future<SCVipHomeRes> vipHome();
Future<SCVipPreviewRes> vipPreview({
int? targetLevel,
String? targetLevelCode,
});
Future<SCVipPurchaseRes> vipPurchase({
required int targetLevel,
required String bizIdempotentKey,
});
Future<SCVipOrdersRes> vipOrders({int cursor = 1, int limit = 20});
}

View File

@ -1,6 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:yumi/shared/tools/sc_loading_manager.dart'; import 'package:yumi/shared/tools/sc_loading_manager.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
@ -11,7 +11,7 @@ import 'package:yumi/shared/data_sources/sources/remote/net/sc_logger.dart';
NetworkClient get http => _httpInstance; NetworkClient get http => _httpInstance;
late final NetworkClient _httpInstance = NetworkClient(); final NetworkClient _httpInstance = NetworkClient();
final RegExp _encryptedRoutePattern = RegExp(r'^[0-9a-f]{32,}$'); final RegExp _encryptedRoutePattern = RegExp(r'^[0-9a-f]{32,}$');
@ -439,13 +439,15 @@ class ResponseData<T> {
Map<String, dynamic> json, { Map<String, dynamic> json, {
T Function(dynamic)? fromJsonT, T Function(dynamic)? fromJsonT,
}) { }) {
final dynamic responseBody =
json.containsKey('body') ? json['body'] : json['data'];
if (fromJsonT == null) { if (fromJsonT == null) {
return ResponseData( return ResponseData(
status: json['status'], status: json['status'],
errorCode: json['errorCode'], errorCode: json['errorCode'],
errorCodeName: json['errorCodeName'], errorCodeName: json['errorCodeName'],
errorMsg: json['errorMsg'], errorMsg: json['errorMsg'],
body: json['body'], body: responseBody,
time: json['time'], time: json['time'],
); );
} else { } else {
@ -454,7 +456,7 @@ class ResponseData<T> {
errorCode: json['errorCode'], errorCode: json['errorCode'],
errorCodeName: json['errorCodeName'], errorCodeName: json['errorCodeName'],
errorMsg: json['errorMsg'], errorMsg: json['errorMsg'],
body: json['body'] != null ? _parseData(json['body'], fromJsonT) : null, body: responseBody != null ? _parseData(responseBody, fromJsonT) : null,
time: json['time'], time: json['time'],
); );
} }
@ -486,8 +488,8 @@ class TimeOutInterceptor extends Interceptor {
if (err.type == DioExceptionType.connectionTimeout || if (err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.receiveTimeout || err.type == DioExceptionType.receiveTimeout ||
err.type == DioExceptionType.sendTimeout) { err.type == DioExceptionType.sendTimeout) {
print('超时错误: URL => ${err.requestOptions.baseUrl}'); debugPrint('超时错误: URL => ${err.requestOptions.baseUrl}');
print('当前使用的Host: ${SCGlobalConfig.apiHost}'); debugPrint('当前使用的Host: ${SCGlobalConfig.apiHost}');
} }
// //
@ -510,7 +512,7 @@ class TimeOutInterceptor extends Interceptor {
_timers[requestKey] = Timer(Duration(seconds: timeoutSeconds), () { _timers[requestKey] = Timer(Duration(seconds: timeoutSeconds), () {
SCLoadingManager.hide(); SCLoadingManager.hide();
if (!cancelToken.isCancelled) { if (!cancelToken.isCancelled) {
cancelToken.cancel('请求超时(${timeoutSeconds}'); cancelToken.cancel('请求超时($timeoutSeconds秒');
// //
_timers.remove(requestKey); _timers.remove(requestKey);
} }

View File

@ -0,0 +1,79 @@
import 'package:yumi/shared/business_logic/models/res/sc_vip_res.dart';
import 'package:yumi/shared/business_logic/repositories/vip_repository.dart';
import 'package:yumi/shared/data_sources/sources/remote/net/network_client.dart';
class SCVipRepositoryImp implements SocialChatVipRepository {
static SCVipRepositoryImp? _instance;
SCVipRepositoryImp._internal();
factory SCVipRepositoryImp() {
return _instance ??= SCVipRepositoryImp._internal();
}
@override
Future<SCVipStatusRes> vipStatus() async {
final result = await http.get<SCVipStatusRes>(
'/app/vip/status',
fromJson: (json) => SCVipStatusRes.fromJson(json),
);
return result;
}
@override
Future<SCVipHomeRes> vipHome() async {
final result = await http.get<SCVipHomeRes>(
'/app/vip/home',
fromJson: (json) => SCVipHomeRes.fromJson(json),
);
return result;
}
@override
Future<SCVipPreviewRes> vipPreview({
int? targetLevel,
String? targetLevelCode,
}) async {
if (targetLevel == null && (targetLevelCode?.isEmpty ?? true)) {
throw ArgumentError('targetLevel or targetLevelCode is required');
}
final params = <String, dynamic>{};
if (targetLevel != null) {
params['targetLevel'] = targetLevel;
}
if (targetLevelCode != null && targetLevelCode.isNotEmpty) {
params['targetLevelCode'] = targetLevelCode;
}
final result = await http.post<SCVipPreviewRes>(
'/app/vip/preview',
data: params,
fromJson: (json) => SCVipPreviewRes.fromJson(json),
);
return result;
}
@override
Future<SCVipPurchaseRes> vipPurchase({
required int targetLevel,
required String bizIdempotentKey,
}) async {
final result = await http.post<SCVipPurchaseRes>(
'/app/vip/purchase',
data: {'targetLevel': targetLevel, 'bizIdempotentKey': bizIdempotentKey},
fromJson: (json) => SCVipPurchaseRes.fromJson(json),
);
return result;
}
@override
Future<SCVipOrdersRes> vipOrders({int cursor = 1, int limit = 20}) async {
final result = await http.get<SCVipOrdersRes>(
'/app/vip/orders',
queryParams: {'cursor': cursor, 'limit': limit},
fromJson: (json) => SCVipOrdersRes.fromJson(json),
);
return result;
}
}

View File

@ -50,41 +50,20 @@ class RoomMusicPlayerBar extends StatelessWidget {
Row( Row(
children: [ children: [
Expanded( Expanded(
child: Column( child: Text(
crossAxisAlignment: CrossAxisAlignment.start, current.title,
children: [ maxLines: 1,
Text( overflow: TextOverflow.ellipsis,
current.title, style: TextStyle(
maxLines: 1, color: Colors.white,
overflow: TextOverflow.ellipsis, fontSize: 14.sp,
style: TextStyle( fontWeight: FontWeight.w700,
color: Colors.white, ),
fontSize: 14.sp,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 4.w),
Text(
_formatDuration(position),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.62),
fontSize: 11.sp,
),
),
],
),
),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => manager.stop(clearCurrent: true),
child: SizedBox(
width: 40.w,
height: 40.w,
child: const Icon(Icons.close, color: Colors.white),
), ),
), ),
], ],
), ),
SizedBox(height: 6.w),
Row( Row(
children: [ children: [
Text( Text(

View File

@ -17,6 +17,7 @@
- 已修正本地音乐扫描器的兼容性风险:扫描 MP3 时不再直接读取 `SongModel.fileExtension/displayName/title` 等可能因 Android MediaStore 返回空值而触发异常的 getter改为从插件原始 map 安全取值,并对文件存在性检查和扫描失败做兜底。 - 已修正本地音乐扫描器的兼容性风险:扫描 MP3 时不再直接读取 `SongModel.fileExtension/displayName/title` 等可能因 Android MediaStore 返回空值而触发异常的 getter改为从插件原始 map 安全取值,并对文件存在性检查和扫描失败做兜底。
- 已按最新音乐页与房间播放器设计继续对稿:音乐管理页右上角只保留添加按钮,排序/编辑按钮移到 `Total songs` 行末;空态不再展示搜索框,搜索框圆角收到 `17`;歌曲 item 改为 `#08251E` 背景和纵向渐隐描边,播放/暂停按钮移到右侧;左滑删除改为固定 `60` 宽删除按钮,需要点击删除按钮后才删除;音乐相关页面点击波浪反馈已收口移除。 - 已按最新音乐页与房间播放器设计继续对稿:音乐管理页右上角只保留添加按钮,排序/编辑按钮移到 `Total songs` 行末;空态不再展示搜索框,搜索框圆角收到 `17`;歌曲 item 改为 `#08251E` 背景和纵向渐隐描边,播放/暂停按钮移到右侧;左滑删除改为固定 `60` 宽删除按钮,需要点击删除按钮后才删除;音乐相关页面点击波浪反馈已收口移除。
- 已重做房间内音乐入口与播放器:右侧音乐浮标不再旋转,并移动到游戏图标上方;点击浮标改为在房间内打开/隐藏播放器,不再直接进入歌曲管理页;房间播放器使用新增的关闭、最小化、上一首、下一首、进入管理页等素材;当前用户在麦上推送音乐时,自己麦位头像左下角会显示并旋转 `用户正在播放bgm` 标识。 - 已重做房间内音乐入口与播放器:右侧音乐浮标不再旋转,并移动到游戏图标上方;点击浮标改为在房间内打开/隐藏播放器,不再直接进入歌曲管理页;房间播放器使用新增的关闭、最小化、上一首、下一首、进入管理页等素材;当前用户在麦上推送音乐时,自己麦位头像左下角会显示并旋转 `用户正在播放bgm` 标识。
- 已继续修正音乐管理页交互细节:歌曲删除按钮现改为与 item 同一行滑出,不再作为底层背景露出;排序拖拽时使用透明 proxy去掉默认白边音乐管理页底部播放器去掉进度条上方多余当前时间和右侧关闭按钮音乐管理页、文件夹页和歌曲选择页的路由切换改为深色 route减少进入/添加音乐时的白屏闪烁。
- 已补齐 `en/ar/tr/bn` 四套现有多语言文案;当前项目声明支持 `zh`,但仓库里暂无 `assets/l10n/intl_zh.json`,因此中文兜底仍由页面内 fallback 文案承担。 - 已补齐 `en/ar/tr/bn` 四套现有多语言文案;当前项目声明支持 `zh`,但仓库里暂无 `assets/l10n/intl_zh.json`,因此中文兜底仍由页面内 fallback 文案承担。
- 验证进度:`flutter pub get` 已通过;音乐相关目录与房间接线文件的定向 `flutter analyze` 无 error/warning当前仅 `rtc_manager.dart``sc_seat_item.dart` 保留项目原有 info 级别风格/废弃 API 提示;全量 `flutter analyze` 仍被仓库既有测试依赖缺失和测试 API 问题阻断;`flutter build apk --debug` 已通过并生成 `build/app/outputs/flutter-apk/app-debug.apk` - 验证进度:`flutter pub get` 已通过;音乐相关目录与房间接线文件的定向 `flutter analyze` 无 error/warning当前仅 `rtc_manager.dart``sc_seat_item.dart` 保留项目原有 info 级别风格/废弃 API 提示;全量 `flutter analyze` 仍被仓库既有测试依赖缺失和测试 API 问题阻断;`flutter build apk --debug` 已通过并生成 `build/app/outputs/flutter-apk/app-debug.apk`