yumi-flutter/lib/modules/gift/cp_rights/cp_rights_guide_page.dart
2026-05-23 10:08:54 +08:00

929 lines
28 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.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/services/audio/rtc_manager.dart';
import 'package:yumi/shared/business_logic/models/res/sc_cp_rights_config_res.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.dart';
import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart';
import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart';
enum _CpGuideTab { note, rights }
enum _CpRightsCategory { cp, brother, sisters }
class CpRightsGuidePage extends StatefulWidget {
const CpRightsGuidePage({super.key, this.cpUserId});
final String? cpUserId;
@override
State<CpRightsGuidePage> createState() => _CpRightsGuidePageState();
}
class _CpRightsGuidePageState extends State<CpRightsGuidePage> {
static const String _assetBase = "sc_images/room/cp_rights";
static const Color _textColor = Color(0xff543356);
static const Color _accentColor = Color(0xffff2a70);
static const String _effectSuppressReason = "cp_rights_guide_page";
static const String _roomRocketPagLaunchDialogTag =
"showRoomRocketPagLaunchDialog";
_CpGuideTab _tab = _CpGuideTab.note;
_CpRightsCategory _rightsCategory = _CpRightsCategory.cp;
RtcProvider? _suppressedRtcProvider;
bool _roomEffectsSuppressed = false;
bool _restoreRoomVisualEffects = false;
final Map<_CpRightsCategory, List<_CpRightsLevel>> _rightsRowsByCategory = {};
final Set<_CpRightsCategory> _loadingRightsCategories = {};
final Set<_CpRightsCategory> _loadedRightsCategories = {};
final Set<_CpRightsCategory> _failedRightsCategories = {};
@override
void initState() {
super.initState();
_suppressRoomEffects();
}
@override
void dispose() {
_releaseRoomEffects();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xffffe7eb),
body: Stack(
children: [
Positioned.fill(child: _buildBackground()),
SafeArea(
top: true,
bottom: false,
child: Column(
children: [
_buildNavigationBar(),
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.only(bottom: 28.w),
child: Column(
children: [
SizedBox(height: 15.w),
_buildTopTabs(),
SizedBox(height: 14.w),
if (_tab == _CpGuideTab.note)
_buildNoteContent()
else
_buildRightsContent(),
],
),
),
),
],
),
),
],
),
);
}
Widget _buildBackground() {
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xffffd8e6), Color(0xffffe7eb)],
),
),
child: Opacity(
opacity: 0.6,
child: Image.asset(
"$_assetBase/sc_cp_rights_bg.png",
fit: BoxFit.cover,
),
),
);
}
Widget _buildNavigationBar() {
return SizedBox(
height: 44.w,
child: Stack(
alignment: Alignment.center,
children: [
PositionedDirectional(
start: 0,
top: 0,
bottom: 0,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Navigator.of(context).maybePop(),
child: SizedBox(
width: 44.w,
child: Center(
child: Transform.flip(
flipX: SCGlobalConfig.lang == "ar",
child: Icon(
Icons.arrow_back_ios_new,
color: Colors.black,
size: 18.w,
),
),
),
),
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 54.w),
child: Text(
"Gifts For Close Friends",
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
textScaler: TextScaler.noScaling,
style: TextStyle(
color: Colors.black,
fontSize: 18.sp,
height: 1,
fontWeight: FontWeight.w500,
decoration: TextDecoration.none,
),
),
),
],
),
);
}
Widget _buildTopTabs() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildMainTab(label: "Note", tab: _CpGuideTab.note),
SizedBox(width: 44.w),
_buildMainTab(label: "Rights", tab: _CpGuideTab.rights),
],
);
}
Widget _buildMainTab({required String label, required _CpGuideTab tab}) {
final selected = _tab == tab;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (_tab == tab) {
return;
}
setState(() => _tab = tab);
if (tab == _CpGuideTab.rights) {
_ensureRightsConfigLoaded(_rightsCategory);
}
},
child: SizedBox(
width: 116.w,
height: 36.w,
child: Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: Image.asset(
selected
? "$_assetBase/sc_cp_rights_tab_selected.png"
: "$_assetBase/sc_cp_rights_tab_unselected.png",
fit: BoxFit.fill,
),
),
Text(
label,
textScaler: TextScaler.noScaling,
style: TextStyle(
color: selected ? Colors.white : _textColor,
fontSize: 14.sp,
height: 1,
fontWeight: FontWeight.w400,
decoration: TextDecoration.none,
),
),
],
),
),
);
}
Widget _buildNoteContent() {
return Column(
children: [
_CpOrnatePanel(
assetBase: _assetBase,
title: "What is a close friendship?",
height: 223.w,
child: Padding(
padding: EdgeInsets.fromLTRB(20.w, 67.w, 20.w, 18.w),
child: Text(
"The \"Best Friends\" feature is a brand-new interactive way to celebrate close relationships between users. By sending gifts to someone and requesting to become best friends, you can form a best friend connection once they accept.\n"
"'CP' connections can only be formed between a man and a woman, while \"brother\" and \"sister\" connections have no gender restrictions.",
textAlign: TextAlign.left,
textScaler: TextScaler.noScaling,
style: _bodyStyle(),
),
),
),
SizedBox(height: 12.w),
_CpOrnatePanel(
assetBase: _assetBase,
title: "How to Form Relationships?",
height: 940.w,
child: Padding(
padding: EdgeInsets.fromLTRB(20.w, 67.w, 20.w, 16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_bodyText(
"Request for a Gift Panel in a Group Chat Room",
color: _accentColor,
fontWeight: FontWeight.w500,
),
SizedBox(height: 5.w),
_bodyText(
"1. In the room, tap [Gift Panel] > select [Best Friends];",
),
SizedBox(height: 8.w),
_noteImage("sc_cp_rights_note_gift_panel.png", 122),
SizedBox(height: 13.w),
_bodyText(
"2. Choose a gift tailored to a specific type of close friend; once you send it, a friend request will be automatically sent;",
),
SizedBox(height: 10.w),
_noteImage("sc_cp_rights_note_relation_types.png", 42),
SizedBox(height: 13.w),
_bodyText(
"3. The other party must accept the match within 24 hours;",
),
SizedBox(height: 8.w),
_noteImage("sc_cp_rights_note_invitation.png", 408),
SizedBox(height: 9.w),
_bodyText(
"*If the other party does not respond within 24 hours of the request being sent, the connection will be automatically declined.",
color: _accentColor,
fontSize: 10,
lineHeight: 1.4,
),
],
),
),
),
SizedBox(height: 12.w),
_CpOrnatePanel(
assetBase: _assetBase,
title: "Intimacy Level",
height: 174.w,
child: Padding(
padding: EdgeInsets.fromLTRB(20.w, 67.w, 20.w, 16.w),
child: _bodyText(
"Intimacy Points are a numerical representation of the level of closeness between two players. 1 Gold Coin equals 1 Intimacy Point, and you can increase your Intimacy Points by sending gifts to your best friend every day.",
),
),
),
SizedBox(height: 12.w),
_CpOrnatePanel(
assetBase: _assetBase,
title: "FAQ",
height: 486.w,
child: Padding(
padding: EdgeInsets.fromLTRB(20.w, 67.w, 20.w, 18.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
_FaqItem(
question:
"Q1: Does giving someone a regular gift increase intimacy?",
answer: "No increase.",
),
_FaqItem(
question:
"Q2: Is there a limit to the number of relationships?",
answer:
"You can only have one CP, and a maximum of three brothers or sisters. More slots will be added in the future.",
),
_FaqItem(
question: "Q3: Who can I invite to be a CP?",
answer:
"As long as neither party has a CP, you can send a CP gift to extend a relationship invitation.",
),
_FaqItem(
question:
"Q4: If my application is denied, will the Best Friend Gift be refunded?",
answer:
"Experience points are awarded the moment the gift is sent. If the request expires or is rejected, the gift will not be returned.",
),
_FaqItem(
question:
"Q5: Can the termination of the relationship be reversed?",
answer:
"Once the relationship is dissolved, all rights and interests will be reclaimed by the platform. The action cannot be undone; the relationship can only be reestablished.",
isLast: true,
),
],
),
),
),
],
);
}
Widget _noteImage(String name, double height) {
return ClipRRect(
borderRadius: BorderRadius.circular(8.w),
child: Image.asset(
"$_assetBase/$name",
width: 310.w,
height: height.w,
fit: BoxFit.fill,
),
);
}
TextStyle _bodyStyle({
Color color = _textColor,
FontWeight fontWeight = FontWeight.w400,
double fontSize = 12,
double lineHeight = 1.333,
}) {
return TextStyle(
color: color,
fontSize: fontSize.sp,
height: lineHeight,
fontWeight: fontWeight,
decoration: TextDecoration.none,
);
}
Widget _bodyText(
String value, {
Color color = _textColor,
FontWeight fontWeight = FontWeight.w400,
double fontSize = 12,
double lineHeight = 1.333,
}) {
return Text(
value,
textAlign: TextAlign.left,
textScaler: TextScaler.noScaling,
style: _bodyStyle(
color: color,
fontWeight: fontWeight,
fontSize: fontSize,
lineHeight: lineHeight,
),
);
}
Widget _buildRightsContent() {
final rows = _currentRightsRowsFor(_rightsCategory);
final isInitialLoading =
_loadingRightsCategories.contains(_rightsCategory) &&
!_rightsRowsByCategory.containsKey(_rightsCategory);
final hasLoadError =
_failedRightsCategories.contains(_rightsCategory) &&
!_rightsRowsByCategory.containsKey(_rightsCategory);
return Column(
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 52.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildRightsCategoryTab("CP", _CpRightsCategory.cp),
_buildRightsCategoryTab("Brother", _CpRightsCategory.brother),
_buildRightsCategoryTab("Sisters", _CpRightsCategory.sisters),
],
),
),
SizedBox(height: 13.w),
if (hasLoadError)
_buildRightsLoadError(_rightsCategory)
else
Stack(
alignment: Alignment.center,
children: [
_buildRightsTable(rows),
if (isInitialLoading)
SizedBox(
width: 22.w,
height: 22.w,
child: CircularProgressIndicator(
strokeWidth: 2.w,
color: _accentColor,
),
),
],
),
],
);
}
Widget _buildRightsCategoryTab(String label, _CpRightsCategory category) {
final selected = _rightsCategory == category;
final textStyle = TextStyle(
color: selected ? _accentColor : _textColor.withValues(alpha: 0.5),
fontSize: 16.sp,
height: 1,
fontWeight: FontWeight.w400,
decoration: TextDecoration.none,
);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (_rightsCategory == category) {
return;
}
setState(() => _rightsCategory = category);
_ensureRightsConfigLoaded(category);
},
child: SizedBox(
height: 26.w,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, textScaler: TextScaler.noScaling, style: textStyle),
SizedBox(height: 6.w),
if (selected)
Container(
width: _rightsIndicatorWidth(label, textStyle),
height: 3.w,
decoration: BoxDecoration(
color: _accentColor,
borderRadius: BorderRadius.circular(31.5.w),
),
),
],
),
),
);
}
Widget _buildRightsLoadError(_CpRightsCategory category) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _ensureRightsConfigLoaded(category, force: true),
child: Container(
width: 351.w,
height: 156.w,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12.w),
),
alignment: Alignment.center,
padding: EdgeInsets.symmetric(horizontal: 24.w),
child: Text(
"Failed to load rights. Tap to retry.",
textAlign: TextAlign.center,
textScaler: TextScaler.noScaling,
style: _bodyStyle(color: _accentColor, lineHeight: 1.3),
),
),
);
}
List<_CpRightsLevel> _rightsRowsFor(_CpRightsCategory category) {
switch (category) {
case _CpRightsCategory.cp:
return const [
_CpRightsLevel(level: "Lv.1", intimacy: "--"),
_CpRightsLevel(level: "Lv.2", intimacy: "--"),
_CpRightsLevel(level: "Lv.3", intimacy: "--"),
_CpRightsLevel(level: "Lv.4", intimacy: "--"),
_CpRightsLevel(level: "Lv.5", intimacy: "--"),
];
case _CpRightsCategory.brother:
return const [
_CpRightsLevel(level: "Lv.1", intimacy: "--"),
_CpRightsLevel(level: "Lv.2", intimacy: "--"),
_CpRightsLevel(level: "Lv.3", intimacy: "--"),
_CpRightsLevel(level: "Lv.4", intimacy: "--"),
_CpRightsLevel(level: "Lv.5", intimacy: "--"),
];
case _CpRightsCategory.sisters:
return const [
_CpRightsLevel(level: "Lv.1", intimacy: "--"),
_CpRightsLevel(level: "Lv.2", intimacy: "--"),
_CpRightsLevel(level: "Lv.3", intimacy: "--"),
_CpRightsLevel(level: "Lv.4", intimacy: "--"),
_CpRightsLevel(level: "Lv.5", intimacy: "--"),
];
}
}
List<_CpRightsLevel> _currentRightsRowsFor(_CpRightsCategory category) {
return _rightsRowsByCategory[category] ?? _rightsRowsFor(category);
}
Future<void> _ensureRightsConfigLoaded(
_CpRightsCategory category, {
bool force = false,
}) async {
if (!force &&
(_loadedRightsCategories.contains(category) ||
_loadingRightsCategories.contains(category))) {
return;
}
final relationType = _relationTypeForRightsCategory(category);
setState(() {
_loadingRightsCategories.add(category);
_failedRightsCategories.remove(category);
});
_rightsDebugLog(
'load start relationType=$relationType cpUserId=${widget.cpUserId}',
);
try {
final config = await SCAccountRepository().cpRightsConfig(
relationType: relationType,
cpUserId: widget.cpUserId,
);
final rows = _rightsRowsFromConfig(config, category);
if (rows.isEmpty) {
throw StateError('empty CP rights levels');
}
if (!mounted) {
return;
}
setState(() {
_rightsRowsByCategory[category] = rows;
_loadedRightsCategories.add(category);
_failedRightsCategories.remove(category);
_loadingRightsCategories.remove(category);
});
_rightsDebugLog(
'load success relationType=$relationType levels=${rows.length}',
);
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_loadingRightsCategories.remove(category);
_failedRightsCategories.add(category);
});
_rightsDebugLog('load failed relationType=$relationType error=$error');
}
}
List<_CpRightsLevel> _rightsRowsFromConfig(
SCCpRightsConfigRes config,
_CpRightsCategory category,
) {
final rows = <_CpRightsLevel>[];
for (final level in config.levels) {
final levelNumber = level.level;
final levelName = level.levelName?.trim();
final requiredValue = level.requiredIntimacyValue ?? 0;
rows.add(
_CpRightsLevel(
level:
levelName?.isNotEmpty == true
? levelName!
: (levelNumber != null
? "Lv.$levelNumber"
: "Lv.${rows.length + 1}"),
intimacy: _formatRightsIntimacyValue(requiredValue),
),
);
}
return rows;
}
String _relationTypeForRightsCategory(_CpRightsCategory category) {
switch (category) {
case _CpRightsCategory.cp:
return "CP";
case _CpRightsCategory.brother:
return "BROTHER";
case _CpRightsCategory.sisters:
return "SISTERS";
}
}
String _formatRightsIntimacyValue(int value) {
final text = value.abs().toString();
final buffer = StringBuffer();
for (var index = 0; index < text.length; index++) {
if (index > 0 && (text.length - index) % 3 == 0) {
buffer.write(",");
}
buffer.write(text[index]);
}
return value < 0 ? "-$buffer" : buffer.toString();
}
void _rightsDebugLog(String message) {
if (kDebugMode) {
debugPrint("[CP][Rights] $message");
}
}
Widget _buildRightsTable(List<_CpRightsLevel> rows) {
const tableWidth = 351.0;
const columnWidth = tableWidth / 2;
const headerHeight = 39.0;
const rowHeight = 77.0;
final tableHeight = headerHeight + rows.length * rowHeight;
return ClipRRect(
borderRadius: BorderRadius.circular(12.w),
child: Container(
width: tableWidth.w,
height: tableHeight.w,
color: Colors.white.withValues(alpha: 0.3),
child: Stack(
children: [
Positioned(
top: 0,
bottom: 0,
left: columnWidth.w,
width: columnWidth.w,
child: ColoredBox(color: Colors.white.withValues(alpha: 0.24)),
),
Positioned(
left: 0,
right: 0,
top: headerHeight.w,
child: _tableLine(width: tableWidth),
),
Positioned.fill(
child: Column(
children: [
SizedBox(
height: headerHeight.w,
child: Row(
children: [
_tableHeader("Level", columnWidth),
_tableHeader("Intimacy Level", columnWidth),
],
),
),
for (final row in rows) _buildRightsLevelRow(row),
],
),
),
],
),
),
);
}
Widget _tableHeader(String value, double width) {
return SizedBox(
width: width.w,
child: Center(
child: Text(
value,
textScaler: TextScaler.noScaling,
style: _bodyStyle(fontSize: 12, lineHeight: 1.2),
),
),
);
}
Widget _buildRightsLevelRow(_CpRightsLevel row) {
const tableWidth = 351.0;
const columnWidth = tableWidth / 2;
const rowHeight = 77.0;
return SizedBox(
height: rowHeight.w,
child: Stack(
children: [
Positioned.fill(
child: Row(
children: [
_spanCell(row.level, width: columnWidth, height: rowHeight),
_spanCell(row.intimacy, width: columnWidth, height: rowHeight),
],
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _tableLine(width: tableWidth),
),
],
),
);
}
Widget _spanCell(
String value, {
required double width,
required double height,
}) {
return SizedBox(
width: width.w,
height: height.w,
child: Center(
child: Text(
value,
textScaler: TextScaler.noScaling,
style: _bodyStyle(fontSize: 12, lineHeight: 1.2),
),
),
);
}
Widget _tableLine({required double width}) {
return Container(width: width.w, height: 1.w, color: Colors.white);
}
double _rightsIndicatorWidth(String label, TextStyle style) {
final painter = TextPainter(
text: TextSpan(text: label, style: style),
textDirection: Directionality.of(context),
textScaler: TextScaler.noScaling,
maxLines: 1,
)..layout();
return painter.width.clamp(16.w, 72.w);
}
void _suppressRoomEffects() {
if (_roomEffectsSuppressed) {
return;
}
_roomEffectsSuppressed = true;
try {
final rtcProvider = context.read<RtcProvider>();
_suppressedRtcProvider = rtcProvider;
_restoreRoomVisualEffects = rtcProvider.roomVisualEffectsEnabled;
rtcProvider.setRoomVisualEffectsEnabled(false);
} catch (_) {}
OverlayManager().beginSuppressFloatingScreens(
reason: _effectSuppressReason,
);
SCRoomEffectScheduler().clearDeferredTasks(reason: _effectSuppressReason);
SmartDialog.dismiss(tag: _roomRocketPagLaunchDialogTag);
SCGiftVapSvgaManager().stopPlayback();
SCGiftVapSvgaManager().pauseAnim();
}
void _releaseRoomEffects() {
if (!_roomEffectsSuppressed) {
return;
}
_roomEffectsSuppressed = false;
SCGiftVapSvgaManager().stopPlayback();
OverlayManager().endSuppressFloatingScreens(reason: _effectSuppressReason);
final rtcProvider = _suppressedRtcProvider;
if (rtcProvider != null && _restoreRoomVisualEffects) {
rtcProvider.setRoomVisualEffectsEnabled(true);
}
SCGiftVapSvgaManager().resumeAnim();
_suppressedRtcProvider = null;
}
}
class _CpOrnatePanel extends StatelessWidget {
const _CpOrnatePanel({
required this.assetBase,
required this.title,
required this.height,
required this.child,
});
static const Color _textColor = Color(0xff543356);
final String assetBase;
final String title;
final double height;
final Widget child;
@override
Widget build(BuildContext context) {
final middleHeight = (height - 65.w - 73.w).clamp(1.0, double.infinity);
return SizedBox(
width: 350.w,
height: height,
child: Stack(
children: [
Positioned.fill(
child: Column(
children: [
Image.asset(
"$assetBase/sc_cp_rights_panel_top.png",
width: 350.w,
height: 65.w,
fit: BoxFit.fill,
),
Image.asset(
"$assetBase/sc_cp_rights_panel_mid.png",
width: 350.w,
height: middleHeight,
fit: BoxFit.fill,
),
Image.asset(
"$assetBase/sc_cp_rights_panel_bottom.png",
width: 350.w,
height: 73.w,
fit: BoxFit.fill,
),
],
),
),
Positioned(
top: 24.w,
left: 0,
right: 0,
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
textScaler: TextScaler.noScaling,
style: TextStyle(
color: _textColor,
fontSize: 16.sp,
height: 1,
fontWeight: FontWeight.w400,
decoration: TextDecoration.none,
),
),
),
Positioned(
left: 30.w,
right: 30.w,
top: 44.w,
child: Image.asset(
"$assetBase/sc_cp_rights_panel_divider.png",
height: 20.w,
fit: BoxFit.fill,
),
),
Positioned.fill(child: child),
],
),
);
}
}
class _FaqItem extends StatelessWidget {
const _FaqItem({
required this.question,
required this.answer,
this.isLast = false,
});
static const Color _textColor = Color(0xff543356);
static const Color _accentColor = Color(0xffff2a70);
final String question;
final String answer;
final bool isLast;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: isLast ? 0 : 8.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
question,
textAlign: TextAlign.left,
textScaler: TextScaler.noScaling,
style: TextStyle(
color: _accentColor,
fontSize: 12.sp,
height: 1.333,
fontWeight: FontWeight.w500,
decoration: TextDecoration.none,
),
),
SizedBox(height: 2.w),
Text(
answer,
textAlign: TextAlign.left,
textScaler: TextScaler.noScaling,
style: TextStyle(
color: _textColor,
fontSize: 12.sp,
height: 1.333,
fontWeight: FontWeight.w400,
decoration: TextDecoration.none,
),
),
],
),
);
}
}
class _CpRightsLevel {
const _CpRightsLevel({required this.level, required this.intimacy});
final String level;
final String intimacy;
}