diff --git a/lib/modules/home/popular/follow/sc_room_follow_page.dart b/lib/modules/home/popular/follow/sc_room_follow_page.dart index 5173213..96941d4 100644 --- a/lib/modules/home/popular/follow/sc_room_follow_page.dart +++ b/lib/modules/home/popular/follow/sc_room_follow_page.dart @@ -25,13 +25,17 @@ class SCRoomFollowPage extends SCPageList { class _RoomFollowPageState extends SCPageListState - with WidgetsBindingObserver { + with WidgetsBindingObserver, AutomaticKeepAliveClientMixin { static const Duration _roomListRefreshInterval = Duration(seconds: 15); String? lastId; Timer? _roomListRefreshTimer; bool _isSilentRefreshingRooms = false; bool _wasTickerModeEnabled = false; + bool _hasCompletedInitialLoad = false; + + @override + bool get wantKeepAlive => true; @override void initState() { @@ -71,11 +75,12 @@ class _RoomFollowPageState @override Widget build(BuildContext context) { + super.build(context); return Scaffold( resizeToAvoidBottomInset: false, backgroundColor: Colors.transparent, body: - items.isEmpty && isLoading + !_hasCompletedInitialLoad && items.isEmpty && isLoading ? const SCHomeSkeletonShimmer( builder: _buildFollowSkeletonContent, ) @@ -322,8 +327,14 @@ class _RoomFollowPageState if (roomList.isNotEmpty) { lastId = roomList.last.id; } + if (page == 1) { + _hasCompletedInitialLoad = true; + } onSuccess(roomList); } catch (e) { + if (page == 1) { + _hasCompletedInitialLoad = true; + } if (onErr != null) { onErr(); } @@ -380,9 +391,8 @@ class _RoomFollowPageState } bool changed = false; - final replaceCount = latestRooms.length < items.length - ? latestRooms.length - : items.length; + final replaceCount = + latestRooms.length < items.length ? latestRooms.length : items.length; for (int i = 0; i < replaceCount; i++) { if (!_sameRoom(items[i], latestRooms[i])) { items[i] = latestRooms[i]; diff --git a/lib/modules/home/popular/history/sc_room_history_page.dart b/lib/modules/home/popular/history/sc_room_history_page.dart index 907678b..d2fd796 100644 --- a/lib/modules/home/popular/history/sc_room_history_page.dart +++ b/lib/modules/home/popular/history/sc_room_history_page.dart @@ -25,13 +25,17 @@ class SCRoomHistoryPage extends SCPageList { class _SCRoomHistoryPageState extends SCPageListState - with WidgetsBindingObserver { + with WidgetsBindingObserver, AutomaticKeepAliveClientMixin { static const Duration _roomListRefreshInterval = Duration(seconds: 15); String? lastId; Timer? _roomListRefreshTimer; bool _isSilentRefreshingRooms = false; bool _wasTickerModeEnabled = false; + bool _hasCompletedInitialLoad = false; + + @override + bool get wantKeepAlive => true; @override void initState() { @@ -71,11 +75,12 @@ class _SCRoomHistoryPageState @override Widget build(BuildContext context) { + super.build(context); return Scaffold( resizeToAvoidBottomInset: false, backgroundColor: Colors.transparent, body: - items.isEmpty && isLoading + !_hasCompletedInitialLoad && items.isEmpty && isLoading ? const SCHomeSkeletonShimmer( builder: _buildHistorySkeletonContent, ) @@ -322,8 +327,14 @@ class _SCRoomHistoryPageState if (roomList.isNotEmpty) { lastId = roomList.last.id; } + if (page == 1) { + _hasCompletedInitialLoad = true; + } onSuccess(roomList); } catch (e) { + if (page == 1) { + _hasCompletedInitialLoad = true; + } if (onErr != null) { onErr(); } @@ -380,9 +391,8 @@ class _SCRoomHistoryPageState } bool changed = false; - final replaceCount = latestRooms.length < items.length - ? latestRooms.length - : items.length; + final replaceCount = + latestRooms.length < items.length ? latestRooms.length : items.length; for (int i = 0; i < replaceCount; i++) { if (!_sameRoom(items[i], latestRooms[i])) { items[i] = latestRooms[i]; diff --git a/lib/modules/home/popular/mine/sc_home_mine_page.dart b/lib/modules/home/popular/mine/sc_home_mine_page.dart index 1e7d3ff..df73dfb 100644 --- a/lib/modules/home/popular/mine/sc_home_mine_page.dart +++ b/lib/modules/home/popular/mine/sc_home_mine_page.dart @@ -22,11 +22,14 @@ class SCHomeMinePage extends StatefulWidget { } class _HomeMinePageState extends State - with SingleTickerProviderStateMixin { + with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin { late TabController _tabController; final List _pages = const [SCRoomHistoryPage(), SCRoomFollowPage()]; final List _tabs = []; + @override + bool get wantKeepAlive => true; + @override void initState() { super.initState(); @@ -45,6 +48,7 @@ class _HomeMinePageState extends State @override Widget build(BuildContext context) { + super.build(context); _tabs.clear(); _tabs.add(Tab(text: SCAppLocalizations.of(context)!.recent)); _tabs.add(Tab(text: SCAppLocalizations.of(context)!.followed)); diff --git a/lib/modules/home/popular/party/sc_home_party_page.dart b/lib/modules/home/popular/party/sc_home_party_page.dart index 872aadf..4e9903c 100644 --- a/lib/modules/home/popular/party/sc_home_party_page.dart +++ b/lib/modules/home/popular/party/sc_home_party_page.dart @@ -36,7 +36,10 @@ class SCHomePartyPage extends StatefulWidget { } class _HomePartyPageState extends State - with SingleTickerProviderStateMixin, WidgetsBindingObserver { + with + SingleTickerProviderStateMixin, + WidgetsBindingObserver, + AutomaticKeepAliveClientMixin { static const Duration _roomListRefreshInterval = Duration(seconds: 15); static const Duration _roomCounterHydrationMinGap = Duration(seconds: 20); static const int _roomCounterHydrationLimit = 6; @@ -61,8 +64,12 @@ class _HomePartyPageState extends State bool _isSilentRefreshingRooms = false; bool _isHydratingVisibleRoomCounters = false; bool _wasTickerModeEnabled = false; + bool _hasCompletedInitialRoomLoad = false; DateTime? _lastRoomCounterHydrationAt; + @override + bool get wantKeepAlive => true; + @override void initState() { super.initState(); @@ -101,9 +108,10 @@ class _HomePartyPageState extends State _wasTickerModeEnabled = isTickerModeEnabled; } - @override - Widget build(BuildContext context) { - return Scaffold( + @override + Widget build(BuildContext context) { + super.build(context); + return Scaffold( resizeToAvoidBottomInset: false, backgroundColor: Colors.transparent, body: Stack( @@ -601,10 +609,10 @@ class _HomePartyPageState extends State ); } - Widget _buildRoomsSection() { - if (isLoading && rooms.isEmpty) { - return _buildRoomGridSkeleton(); - } + Widget _buildRoomsSection() { + if (!_hasCompletedInitialRoomLoad && isLoading && rooms.isEmpty) { + return _buildRoomGridSkeleton(); + } if (rooms.isEmpty) { return GestureDetector( behavior: HitTestBehavior.opaque, @@ -977,6 +985,7 @@ class _HomePartyPageState extends State .then((values) { rooms = _mergeLatestRoomsWithCurrentCounters(values); isLoading = false; + _hasCompletedInitialRoomLoad = true; _refreshController.refreshCompleted(); _refreshController.loadComplete(); if (mounted) { @@ -986,10 +995,11 @@ class _HomePartyPageState extends State }) .catchError((e) { _refreshController.loadNoData(); - _refreshController.refreshCompleted(); - isLoading = false; - if (mounted) setState(() {}); - }); + _refreshController.refreshCompleted(); + isLoading = false; + _hasCompletedInitialRoomLoad = true; + if (mounted) setState(() {}); + }); generalManager.loadMainBanner().whenComplete(() { if (!mounted) { return; diff --git a/lib/modules/index/index_page.dart b/lib/modules/index/index_page.dart index f9cdc18..5f784ee 100644 --- a/lib/modules/index/index_page.dart +++ b/lib/modules/index/index_page.dart @@ -39,6 +39,7 @@ class _SCIndexPageState extends State { int _currentIndex = 0; final List _pages = []; + final Set _builtPageIndexes = {0}; final List _bottomItems = []; SCAppGeneralManager? generalProvider; Locale? _lastLocale; @@ -130,7 +131,7 @@ class _SCIndexPageState extends State { child: Scaffold( resizeToAvoidBottomInset: false, backgroundColor: Colors.transparent, - body: _pages[_currentIndex], + body: _buildCachedBody(), bottomNavigationBar: Container( height: 85.w, decoration: BoxDecoration( @@ -167,7 +168,7 @@ class _SCIndexPageState extends State { showSelectedLabels: true, items: _bottomItems, currentIndex: _currentIndex, - onTap: (index) => setState(() => _currentIndex = index), + onTap: _switchBottomTab, ), ), ), @@ -205,6 +206,31 @@ class _SCIndexPageState extends State { _pages.add(MePage2()); } + Widget _buildCachedBody() { + return IndexedStack( + index: _currentIndex, + children: List.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() { final locale = Localizations.localeOf(context); if (_lastLocale == locale && _bottomItems.isNotEmpty) { diff --git a/lib/modules/room/music/room_music_folder_page.dart b/lib/modules/room/music/room_music_folder_page.dart index df2a2dd..5b43578 100644 --- a/lib/modules/room/music/room_music_folder_page.dart +++ b/lib/modules/room/music/room_music_folder_page.dart @@ -40,11 +40,9 @@ class RoomMusicFolderPage extends StatelessWidget { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () async { - final changed = await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => RoomMusicSelectPage(folder: folder), - ), - ); + final changed = await Navigator.of( + context, + ).push(_musicRoute(RoomMusicSelectPage(folder: folder))); if (changed == true && context.mounted) { Navigator.pop(context, true); } @@ -124,3 +122,22 @@ class RoomMusicFolderPage extends StatelessWidget { ); } } + +Route _musicRoute(Widget page) { + return PageRouteBuilder( + 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( + begin: const Offset(1, 0), + end: Offset.zero, + ).chain(CurveTween(curve: Curves.easeOutCubic)); + return SlideTransition(position: animation.drive(tween), child: child); + }, + ); +} diff --git a/lib/modules/room/music/room_music_page.dart b/lib/modules/room/music/room_music_page.dart index 9828de8..812821b 100644 --- a/lib/modules/room/music/room_music_page.dart +++ b/lib/modules/room/music/room_music_page.dart @@ -304,7 +304,16 @@ class _RoomMusicPageState extends State { final bottomPadding = manager.current == null ? 20.w : 190.w; return ReorderableListView.builder( padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, bottomPadding), + buildDefaultDragHandles: false, itemCount: songs.length, + proxyDecorator: (child, index, animation) { + return Material( + color: Colors.transparent, + shadowColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + child: child, + ); + }, onReorder: (oldIndex, newIndex) { manager.reorder(oldIndex, newIndex); }, @@ -355,9 +364,9 @@ class _RoomMusicPageState extends State { SCTts.show(RoomMusicTexts.t(context, "roomMusicNotFound", "未找到音乐")); return; } - final changed = await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => RoomMusicFolderPage(folders: folders)), - ); + final changed = await Navigator.of( + context, + ).push(_musicRoute(RoomMusicFolderPage(folders: folders))); if (changed == true && mounted) { await manager.reload(); } @@ -524,61 +533,85 @@ class _SwipeDeleteTileState extends State<_SwipeDeleteTile> { @override Widget build(BuildContext context) { final actionWidth = 60.w; - return SizedBox( - height: 56.w, - child: Stack( - children: [ - PositionedDirectional( - end: 0, - top: 0, - bottom: 0, - width: actionWidth, + return LayoutBuilder( + builder: (context, constraints) { + return ClipRect( + child: SizedBox( + height: 56.w, child: GestureDetector( behavior: HitTestBehavior.opaque, - onTap: widget.onDelete, - child: Container( - 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, + 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: 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 _musicRoute(Widget page) { + return PageRouteBuilder( + 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( + 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 { const _PlainIconButton({required this.child, required this.onPressed}); diff --git a/lib/modules/room/voice_room_route.dart b/lib/modules/room/voice_room_route.dart index bf13e80..120577d 100644 --- a/lib/modules/room/voice_room_route.dart +++ b/lib/modules/room/voice_room_route.dart @@ -28,6 +28,26 @@ class VoiceRoomRoute implements SCIRouterProvider { return MaterialPageRoute(builder: (_) => page, settings: settings); } + static Route _buildDarkRoute(Widget page, {RouteSettings? settings}) { + return PageRouteBuilder( + 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( + begin: const Offset(1, 0), + end: Offset.zero, + ).chain(CurveTween(curve: Curves.easeOutCubic)); + return SlideTransition(position: animation.drive(tween), child: child); + }, + ); + } + static Future openVoiceRoom( BuildContext context, { bool replace = false, @@ -102,10 +122,12 @@ class VoiceRoomRoute implements SCIRouterProvider { BuildContext context, { bool rootNavigator = false, }) { - return Navigator.of( - context, - rootNavigator: rootNavigator, - ).push(_buildRoute(const RoomMusicPage())); + return Navigator.of(context, rootNavigator: rootNavigator).push( + _buildDarkRoute( + const RoomMusicPage(), + settings: RouteSettings(name: roomMusic), + ), + ); } @override diff --git a/lib/modules/user/vip/vip_detail_page.dart b/lib/modules/user/vip/vip_detail_page.dart index 6360f4c..e005536 100644 --- a/lib/modules/user/vip/vip_detail_page.dart +++ b/lib/modules/user/vip/vip_detail_page.dart @@ -1,8 +1,17 @@ import 'package:flutter/material.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/routes/sc_fluro_navigator.dart'; +import 'package:yumi/app_localizations.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 { const VipDetailPage({super.key}); @@ -12,13 +21,166 @@ class VipDetailPage extends StatefulWidget { } class _VipDetailPageState extends State { + final SCVipRepositoryImp _repository = SCVipRepositoryImp(); + int _selectedLevel = 1; - late bool _showUpdateButton; + bool _isLoading = false; + bool _isPurchasing = false; + int? _previewingLevel; + Object? _loadError; + SCVipHomeRes? _home; + SCVipStatusRes? _status; + SCVipPreviewRes? _preview; @override void initState() { super.initState(); - _showUpdateButton = false; + _loadVipData(); + } + + Future _loadVipData({bool showLoading = true}) async { + if (showLoading) { + setState(() { + _isLoading = true; + _loadError = null; + }); + } + + try { + final results = await Future.wait([ + _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 ?? []; + 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 _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 _purchaseSelectedLevel() async { + if (!_canPurchaseSelected || _isPurchasing) { + return; + } + + final profileManager = Provider.of( + 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 @@ -78,28 +240,7 @@ class _VipDetailPageState extends State { 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), - ], - ), - ), - ), + Expanded(child: _buildBodyContent()), _buildActivateBar(), ], ), @@ -109,6 +250,74 @@ class _VipDetailPageState extends State { ); } + 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() { return SizedBox( height: 44.w, @@ -172,21 +381,25 @@ class _VipDetailPageState extends State { separatorBuilder: (_, __) => SizedBox(width: 30.w), itemBuilder: (context, index) { final level = index + 1; + final config = _levelConfigFor(level); + final enabled = config?.enabled != false; final selected = level == _selectedLevel; return GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => setState(() => _selectedLevel = level), + onTap: () => _selectLevel(level), child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox(height: 4.w), Text( - 'VIP$level', + config?.displayNameText ?? 'VIP$level', style: TextStyle( color: selected ? Colors.white - : Colors.white.withValues(alpha: 0.42), + : Colors.white.withValues( + alpha: enabled ? 0.42 : 0.20, + ), fontSize: 15.sp, fontWeight: FontWeight.w500, ), @@ -252,12 +465,19 @@ class _VipDetailPageState extends State { 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, + end: 160.w, + child: SizedBox( + height: 32.w, + child: Text( + _selectedLevelStatusText, + 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 { final bottomInset = MediaQuery.of(context).padding.bottom; final actionButton = _actionButtonConfigForLevel(_assetLevel); final barOverflow = 20.w + bottomInset; + final canPurchase = + _canPurchaseSelected && + !_isPurchasing && + _previewingLevel != _selectedLevel; return SizedBox( height: 106.5.w, width: double.infinity, @@ -588,7 +812,7 @@ class _VipDetailPageState extends State { ), SizedBox(width: 6.w), Text( - '30.5 M / 30 Days', + _priceText, style: TextStyle( color: const Color(0xFFFFE8A7), fontSize: 16.sp, @@ -596,26 +820,42 @@ class _VipDetailPageState extends State { ), ), 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, + SCDebounceWidget( + onTap: canPurchase ? _purchaseSelectedLevel : () {}, + child: Opacity( + opacity: canPurchase ? 1 : 0.52, + child: Container( + width: 106.w, + height: 42.w, + alignment: Alignment.center, + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage(actionButton.backgroundAssetPath), + fit: BoxFit.fill, + ), + ), + child: + _isPurchasing || _previewingLevel == _selectedLevel + ? SizedBox( + width: 16.w, + height: 16.w, + 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 { } _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(); + final config = _levelConfigFor(level); + if (_home?.configured == false || config?.enabled == false) { + return const _VipActionButtonConfig.disabled(label: 'Unavailable'); } + + 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(); @@ -778,6 +1106,24 @@ class _VipActionButtonConfig { 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 backgroundAssetPath; final Color textColor; diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index 8bc6b6e..a2cd920 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -97,6 +97,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { RoomStartupStatus _roomStartupStatus = RoomStartupStatus.idle; RoomStartupFailureType _roomStartupFailureType = RoomStartupFailureType.none; int _roomStartupFailureToken = 0; + int _roomEntryRequestSerial = 0; bool _isHandlingRoomStartupFailure = false; Timer? _micListPollingTimer; Timer? _onlineUsersPollingTimer; @@ -1635,29 +1636,80 @@ class RealTimeCommunicationManager extends ChangeNotifier { return; } rtmProvider = Provider.of(context, listen: false); - try { - currenRoom = await SCAccountRepository().entryRoom( + _setRoomStartupLoading(); + setRoomVisualEffectsEnabled(true); + VoiceRoomRoute.openVoiceRoom(context); + final entryRequestSerial = ++_roomEntryRequestSerial; + unawaited( + _enterAndInitializeVoiceRoomSession( roomId, + entryRequestSerial: entryRequestSerial, pwd: pwd, - redPackId: redPackId, - needOpenRedenvelope: needOpenRedenvelope, - ); - await initializeRoomSession( needOpenRedenvelope: needOpenRedenvelope, redPackId: redPackId, - ); - notifyListeners(); - } catch (e) { - await resetLocalRoomState(fallbackRtmProvider: rtmProvider); - rethrow; - } + ), + ); } } + Future _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 _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 initializeRoomSession({ bool needOpenRedenvelope = false, String? redPackId, + bool shouldOpenRoomPage = true, }) async { _disableMicListRefreshForCurrentSession = false; _disableOnlineUsersRefreshForCurrentSession = false; @@ -1684,7 +1736,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { } _setRoomStartupLoading(); setRoomVisualEffectsEnabled(true); - VoiceRoomRoute.openVoiceRoom(context!); + if (shouldOpenRoomPage) { + VoiceRoomRoute.openVoiceRoom(context!); + } unawaited(_bootstrapEnteredVoiceRoomSession()); } @@ -2119,6 +2173,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { ///清空列表数据 void _clearData() { + _roomEntryRequestSerial += 1; _stopRoomStatePolling(); _resetHeartbeatTracking(); _resetAgoraTracking(); diff --git a/lib/shared/business_logic/models/res/login_res.dart b/lib/shared/business_logic/models/res/login_res.dart index 500cdaa..b7d2f3e 100644 --- a/lib/shared/business_logic/models/res/login_res.dart +++ b/lib/shared/business_logic/models/res/login_res.dart @@ -505,7 +505,13 @@ class SocialChatUserProfile { } PropsResources? getVIP() { - return null; + PropsResources? pr; + _useProps?.forEach((value) { + if (value.propsResources?.type == SCPropsType.NOBLE_VIP.name) { + pr = value.propsResources; + } + }); + return pr; } PropsResources? getDataCard() { diff --git a/lib/shared/business_logic/models/res/sc_vip_res.dart b/lib/shared/business_logic/models/res/sc_vip_res.dart new file mode 100644 index 0000000..9335686 --- /dev/null +++ b/lib/shared/business_logic/models/res/sc_vip_res.dart @@ -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 toJson() { + final map = {}; + 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 toJson() { + final map = {}; + 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 toJson() { + final map = {}; + 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? 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? _levels; + + String? get userId => _userId; + + String? get sysOrigin => _sysOrigin; + + bool? get configured => _configured; + + SCVipStatusRes? get state => _state; + + List get levels => _levels ?? []; + + Map toJson() { + final map = {}; + 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 toJson() { + final map = {}; + 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 toJson() { + final map = {}; + map['success'] = _success; + if (_order != null) map['order'] = _order?.toJson(); + if (_state != null) map['state'] = _state?.toJson(); + return map; + } +} + +class SCVipOrdersRes { + SCVipOrdersRes({List? 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? _records; + num? _total; + + List get records => _records ?? []; + + num? get total => _total; + + Map toJson() { + final map = {}; + 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 toJson() { + final map = {}; + 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? _asMap(dynamic value) { + if (value is Map) return value; + if (value is Map) return Map.from(value); + return null; +} + +List _asList(dynamic value) { + if (value is List) return value; + return []; +} + +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; +} diff --git a/lib/shared/business_logic/repositories/vip_repository.dart b/lib/shared/business_logic/repositories/vip_repository.dart new file mode 100644 index 0000000..c0def15 --- /dev/null +++ b/lib/shared/business_logic/repositories/vip_repository.dart @@ -0,0 +1,19 @@ +import 'package:yumi/shared/business_logic/models/res/sc_vip_res.dart'; + +abstract class SocialChatVipRepository { + Future vipStatus(); + + Future vipHome(); + + Future vipPreview({ + int? targetLevel, + String? targetLevelCode, + }); + + Future vipPurchase({ + required int targetLevel, + required String bizIdempotentKey, + }); + + Future vipOrders({int cursor = 1, int limit = 20}); +} diff --git a/lib/shared/data_sources/sources/remote/net/network_client.dart b/lib/shared/data_sources/sources/remote/net/network_client.dart index 56bbb94..06efb60 100644 --- a/lib/shared/data_sources/sources/remote/net/network_client.dart +++ b/lib/shared/data_sources/sources/remote/net/network_client.dart @@ -1,6 +1,6 @@ import 'dart:async'; import 'dart:io'; -import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:yumi/shared/tools/sc_loading_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; -late final NetworkClient _httpInstance = NetworkClient(); +final NetworkClient _httpInstance = NetworkClient(); final RegExp _encryptedRoutePattern = RegExp(r'^[0-9a-f]{32,}$'); @@ -439,13 +439,15 @@ class ResponseData { Map json, { T Function(dynamic)? fromJsonT, }) { + final dynamic responseBody = + json.containsKey('body') ? json['body'] : json['data']; if (fromJsonT == null) { return ResponseData( status: json['status'], errorCode: json['errorCode'], errorCodeName: json['errorCodeName'], errorMsg: json['errorMsg'], - body: json['body'], + body: responseBody, time: json['time'], ); } else { @@ -454,7 +456,7 @@ class ResponseData { errorCode: json['errorCode'], errorCodeName: json['errorCodeName'], errorMsg: json['errorMsg'], - body: json['body'] != null ? _parseData(json['body'], fromJsonT) : null, + body: responseBody != null ? _parseData(responseBody, fromJsonT) : null, time: json['time'], ); } @@ -486,8 +488,8 @@ class TimeOutInterceptor extends Interceptor { if (err.type == DioExceptionType.connectionTimeout || err.type == DioExceptionType.receiveTimeout || err.type == DioExceptionType.sendTimeout) { - print('超时错误: URL => ${err.requestOptions.baseUrl}'); - print('当前使用的Host: ${SCGlobalConfig.apiHost}'); + debugPrint('超时错误: URL => ${err.requestOptions.baseUrl}'); + debugPrint('当前使用的Host: ${SCGlobalConfig.apiHost}'); } // 请求出错,取消计时器 @@ -510,7 +512,7 @@ class TimeOutInterceptor extends Interceptor { _timers[requestKey] = Timer(Duration(seconds: timeoutSeconds), () { SCLoadingManager.hide(); if (!cancelToken.isCancelled) { - cancelToken.cancel('请求超时(${timeoutSeconds}秒)'); + cancelToken.cancel('请求超时($timeoutSeconds秒)'); // 从映射中移除计时器 _timers.remove(requestKey); } diff --git a/lib/shared/data_sources/sources/repositories/sc_vip_repository_imp.dart b/lib/shared/data_sources/sources/repositories/sc_vip_repository_imp.dart new file mode 100644 index 0000000..f9eeea2 --- /dev/null +++ b/lib/shared/data_sources/sources/repositories/sc_vip_repository_imp.dart @@ -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 vipStatus() async { + final result = await http.get( + '/app/vip/status', + fromJson: (json) => SCVipStatusRes.fromJson(json), + ); + return result; + } + + @override + Future vipHome() async { + final result = await http.get( + '/app/vip/home', + fromJson: (json) => SCVipHomeRes.fromJson(json), + ); + return result; + } + + @override + Future vipPreview({ + int? targetLevel, + String? targetLevelCode, + }) async { + if (targetLevel == null && (targetLevelCode?.isEmpty ?? true)) { + throw ArgumentError('targetLevel or targetLevelCode is required'); + } + + final params = {}; + if (targetLevel != null) { + params['targetLevel'] = targetLevel; + } + if (targetLevelCode != null && targetLevelCode.isNotEmpty) { + params['targetLevelCode'] = targetLevelCode; + } + + final result = await http.post( + '/app/vip/preview', + data: params, + fromJson: (json) => SCVipPreviewRes.fromJson(json), + ); + return result; + } + + @override + Future vipPurchase({ + required int targetLevel, + required String bizIdempotentKey, + }) async { + final result = await http.post( + '/app/vip/purchase', + data: {'targetLevel': targetLevel, 'bizIdempotentKey': bizIdempotentKey}, + fromJson: (json) => SCVipPurchaseRes.fromJson(json), + ); + return result; + } + + @override + Future vipOrders({int cursor = 1, int limit = 20}) async { + final result = await http.get( + '/app/vip/orders', + queryParams: {'cursor': cursor, 'limit': limit}, + fromJson: (json) => SCVipOrdersRes.fromJson(json), + ); + return result; + } +} diff --git a/lib/ui_kit/widgets/room/music/room_music_player_bar.dart b/lib/ui_kit/widgets/room/music/room_music_player_bar.dart index 0e8c583..3c146c1 100644 --- a/lib/ui_kit/widgets/room/music/room_music_player_bar.dart +++ b/lib/ui_kit/widgets/room/music/room_music_player_bar.dart @@ -50,41 +50,20 @@ class RoomMusicPlayerBar extends StatelessWidget { Row( children: [ Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - current.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - 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), + child: Text( + current.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 14.sp, + fontWeight: FontWeight.w700, + ), ), ), ], ), + SizedBox(height: 6.w), Row( children: [ Text( diff --git a/需求进度.md b/需求进度.md index f92b411..05cf4c1 100644 --- a/需求进度.md +++ b/需求进度.md @@ -17,6 +17,7 @@ - 已修正本地音乐扫描器的兼容性风险:扫描 MP3 时不再直接读取 `SongModel.fileExtension/displayName/title` 等可能因 Android MediaStore 返回空值而触发异常的 getter,改为从插件原始 map 安全取值,并对文件存在性检查和扫描失败做兜底。 - 已按最新音乐页与房间播放器设计继续对稿:音乐管理页右上角只保留添加按钮,排序/编辑按钮移到 `Total songs` 行末;空态不再展示搜索框,搜索框圆角收到 `17`;歌曲 item 改为 `#08251E` 背景和纵向渐隐描边,播放/暂停按钮移到右侧;左滑删除改为固定 `60` 宽删除按钮,需要点击删除按钮后才删除;音乐相关页面点击波浪反馈已收口移除。 - 已重做房间内音乐入口与播放器:右侧音乐浮标不再旋转,并移动到游戏图标上方;点击浮标改为在房间内打开/隐藏播放器,不再直接进入歌曲管理页;房间播放器使用新增的关闭、最小化、上一首、下一首、进入管理页等素材;当前用户在麦上推送音乐时,自己麦位头像左下角会显示并旋转 `用户正在播放bgm` 标识。 +- 已继续修正音乐管理页交互细节:歌曲删除按钮现改为与 item 同一行滑出,不再作为底层背景露出;排序拖拽时使用透明 proxy,去掉默认白边;音乐管理页底部播放器去掉进度条上方多余当前时间和右侧关闭按钮;音乐管理页、文件夹页和歌曲选择页的路由切换改为深色 route,减少进入/添加音乐时的白屏闪烁。 - 已补齐 `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`。