yumi-flutter/lib/ui_kit/widgets/room/rocket/room_rocket_dialog.dart
2026-06-02 19:57:41 +08:00

2110 lines
59 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_status_res.dart';
import 'package:yumi/ui_kit/components/custom_cached_image.dart';
import 'package:yumi/ui_kit/components/sc_rotating_dots_loading.dart';
import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_network_svga_widget.dart';
class RoomRocketDialog extends StatelessWidget {
const RoomRocketDialog({
super.key,
this.status,
this.progressStage,
this.bottomStage,
this.levels,
this.selectedLevel = 0,
this.onLevelSelected,
this.rewards,
this.rewardGroups,
this.selectedRewardTab = RoomRocketRewardTab.top1,
this.onRewardTabSelected,
this.crew,
this.onClose,
this.onOpenRank,
this.onRewardSelected,
this.onOpenRecord,
this.onOpenNote,
});
final SCRoomRocketStatusRes? status;
final RoomRocketProgressStage? progressStage;
final RoomRocketBottomStage? bottomStage;
final List<RoomRocketLevelItem>? levels;
final int selectedLevel;
final ValueChanged<int>? onLevelSelected;
final List<RoomRocketRewardItem>? rewards;
final RoomRocketRewardGroups? rewardGroups;
final RoomRocketRewardTab selectedRewardTab;
final ValueChanged<RoomRocketRewardTab>? onRewardTabSelected;
final List<RoomRocketCrewMember>? crew;
final VoidCallback? onClose;
final VoidCallback? onOpenRank;
final ValueChanged<RoomRocketRewardItem>? onRewardSelected;
final VoidCallback? onOpenRecord;
final VoidCallback? onOpenNote;
@override
Widget build(BuildContext context) {
final resolvedLevels = _normalizeLevels(levels);
final resolvedRewards = _normalizeRewards(
rewardGroups?.rewardsFor(selectedRewardTab) ?? rewards,
useFallback: rewardGroups == null,
);
final resolvedCrew = _normalizeCrew(crew, status);
final resolvedSelectedLevel =
selectedLevel.clamp(0, math.max(0, resolvedLevels.length - 1)).toInt();
final selectedLevelItem = resolvedLevels[resolvedSelectedLevel];
final percent = _percentForSelectedLevel(status, selectedLevelItem.level);
final displayPercent = _displayPercentForSelectedLevel(
status,
selectedLevelItem.level,
percent,
);
final progressPercent = _progressPercentForSelectedLevel(
status,
selectedLevelItem.level,
percent,
);
final resolvedProgressStage =
progressStage ?? RoomRocketProgressStage.fromPercent(progressPercent);
final resolvedBottomStage =
bottomStage ??
(resolvedCrew.isEmpty
? RoomRocketBottomStage.empty
: RoomRocketBottomStage.active);
final round = (status?.roundNo ?? status?.level ?? 1).clamp(1, 99).toInt();
return Directionality(
textDirection: TextDirection.ltr,
child: Material(
color: Colors.transparent,
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onClose,
child: Container(color: Colors.black.withValues(alpha: 0.60)),
),
),
Positioned.fill(
child: LayoutBuilder(
builder: (context, constraints) {
final scale = math.min(
constraints.maxWidth / _designWidth,
constraints.maxHeight / _designHeight,
);
return Align(
alignment: Alignment.topCenter,
child: SizedBox(
width: _designWidth * scale,
height: _designHeight * scale,
child: _RocketCanvas(
scale: scale,
percent: percent,
displayPercent: displayPercent,
progressStage: resolvedProgressStage,
bottomStage: resolvedBottomStage,
status: status,
round: round,
levels: resolvedLevels,
selectedLevel: resolvedSelectedLevel,
selectedLevelItem: selectedLevelItem,
onLevelSelected: onLevelSelected,
rewards: resolvedRewards,
selectedRewardTab: selectedRewardTab,
onRewardTabSelected: onRewardTabSelected,
crew: resolvedCrew,
onClose: onClose,
onOpenRank: onOpenRank,
onRewardSelected: onRewardSelected,
onOpenRecord: onOpenRecord,
onOpenNote: onOpenNote,
),
),
);
},
),
),
],
),
),
);
}
static List<RoomRocketLevelItem> _normalizeLevels(
List<RoomRocketLevelItem>? levels,
) {
final source =
levels
?.where((item) => item.label.trim().isNotEmpty)
.toList(growable: false) ??
const <RoomRocketLevelItem>[];
if (source.isNotEmpty) {
return source;
}
return List<RoomRocketLevelItem>.generate(
6,
(index) =>
RoomRocketLevelItem(label: 'Lv.${index + 1}', level: index + 1),
growable: false,
);
}
static List<RoomRocketRewardItem> _normalizeRewards(
List<RoomRocketRewardItem>? rewards, {
required bool useFallback,
}) {
final source =
rewards?.where((item) => item.amount.trim().isNotEmpty).toList() ??
const <RoomRocketRewardItem>[];
if (source.isNotEmpty) {
return source;
}
if (!useFallback) {
return const <RoomRocketRewardItem>[];
}
return const [
RoomRocketRewardItem(asset: _RocketAssets.rewardThumb, amount: '10000'),
RoomRocketRewardItem(asset: _RocketAssets.rewardThumb, amount: '10000'),
RoomRocketRewardItem(asset: _RocketAssets.rewardThumb, amount: '10000'),
RoomRocketRewardItem(asset: _RocketAssets.rewardThumb, amount: '10000'),
];
}
static List<RoomRocketCrewMember> _normalizeCrew(
List<RoomRocketCrewMember>? crew,
SCRoomRocketStatusRes? status,
) {
final hasNoContributors =
status != null && (status.totalContributors ?? 0) <= 0;
final source = crew ?? const <RoomRocketCrewMember>[];
if (source.isNotEmpty) {
return source.take(3).toList();
}
if (hasNoContributors) {
return const <RoomRocketCrewMember>[];
}
return const <RoomRocketCrewMember>[];
}
static double _normalizePercent(num? value) {
if (value == null) {
return 100;
}
final raw = value.toDouble();
final percent = raw > 0 && raw <= 1 ? raw * 100 : raw;
return percent.clamp(0, 100).toDouble();
}
static double _percentForSelectedLevel(
SCRoomRocketStatusRes? status,
int selectedLevel,
) {
final currentLevel =
(status?.currentLevel ?? status?.level?.toInt() ?? selectedLevel)
.clamp(1, 99)
.toInt();
if (selectedLevel < currentLevel) {
return 100;
}
if (selectedLevel > currentLevel) {
return 0;
}
return _normalizePercent(status?.displayPercent ?? status?.energyPercent);
}
static int _displayPercentForSelectedLevel(
SCRoomRocketStatusRes? status,
int selectedLevel,
double percent,
) {
final currentLevel =
(status?.currentLevel ?? status?.level?.toInt() ?? selectedLevel)
.clamp(1, 99)
.toInt();
if (selectedLevel < currentLevel) {
return 100;
}
if (selectedLevel > currentLevel) {
return 0;
}
if (status?.shouldDisplayFullProgress ?? percent >= 100) {
return 100;
}
return percent.round().clamp(0, 99).toInt();
}
static double _progressPercentForSelectedLevel(
SCRoomRocketStatusRes? status,
int selectedLevel,
double percent,
) {
final currentLevel =
(status?.currentLevel ?? status?.level?.toInt() ?? selectedLevel)
.clamp(1, 99)
.toInt();
if (selectedLevel < currentLevel) {
return 100;
}
if (selectedLevel > currentLevel) {
return 0;
}
if (status?.shouldDisplayFullProgress ?? percent >= 100) {
return 100;
}
return percent;
}
}
@visibleForTesting
String debugRoomRocketTitleText({required int level, required int round}) {
return _roomRocketTitleText(level: level, round: round);
}
@visibleForTesting
int debugRoomRocketDisplayPercentText({
required SCRoomRocketStatusRes? status,
required int selectedLevel,
}) {
final percent = RoomRocketDialog._percentForSelectedLevel(
status,
selectedLevel,
);
return RoomRocketDialog._displayPercentForSelectedLevel(
status,
selectedLevel,
percent,
);
}
String _roomRocketTitleText({required int level, required int round}) {
final normalizedLevel = level.clamp(1, 99).toInt();
final normalizedRound = round.clamp(1, 99).toInt();
if (normalizedRound <= 1) {
return 'Lv.$normalizedLevel Today';
}
return 'Lv.$normalizedLevel · Round $normalizedRound Today';
}
class RoomRocketRewardItem {
const RoomRocketRewardItem({
this.asset,
this.imageUrl,
required this.amount,
this.rewardType = 'GOLD',
this.expireDays = 0,
});
final String? asset;
final String? imageUrl;
final String amount;
final String rewardType;
final int expireDays;
bool get isGoldReward {
final normalizedType = rewardType.trim().toUpperCase();
return normalizedType == 'GOLD' ||
normalizedType == 'COIN' ||
normalizedType == 'COINS';
}
String get displayText {
if (isGoldReward) {
return amount;
}
return _formatDayText(expireDays > 0 ? expireDays.toString() : amount);
}
static String _formatDayText(String value) {
final text = value.trim();
if (text.isEmpty) {
return text;
}
final dayMatch = RegExp(
r'^(\d+(?:\.\d+)?)\s*d$',
caseSensitive: false,
).firstMatch(text);
final dayValue = dayMatch?.group(1) ?? _normalizedNumber(text);
return dayValue == null ? text : '${dayValue}d';
}
static String? _normalizedNumber(String value) {
final parsed = num.tryParse(value);
if (parsed == null) {
return null;
}
return parsed % 1 == 0 ? parsed.toInt().toString() : parsed.toString();
}
}
enum RoomRocketRewardTab { top1, setOff, inRoom }
class RoomRocketRewardGroups {
const RoomRocketRewardGroups({
this.top1 = const <RoomRocketRewardItem>[],
this.setOff = const <RoomRocketRewardItem>[],
this.inRoom = const <RoomRocketRewardItem>[],
});
final List<RoomRocketRewardItem> top1;
final List<RoomRocketRewardItem> setOff;
final List<RoomRocketRewardItem> inRoom;
List<RoomRocketRewardItem> rewardsFor(RoomRocketRewardTab tab) {
switch (tab) {
case RoomRocketRewardTab.top1:
return top1;
case RoomRocketRewardTab.setOff:
return setOff;
case RoomRocketRewardTab.inRoom:
return inRoom;
}
}
}
class RoomRocketLevelItem {
const RoomRocketLevelItem({
required this.label,
this.level = 1,
this.asset,
this.imageUrl,
this.previewImageUrl,
this.animationUrl,
this.progressImageUrl,
this.shakeThresholdPercent = 95,
this.enabled = true,
});
final String label;
final int level;
final String? asset;
final String? imageUrl;
final String? previewImageUrl;
final String? animationUrl;
final String? progressImageUrl;
final num shakeThresholdPercent;
final bool enabled;
}
class RoomRocketCrewMember {
const RoomRocketCrewMember({this.asset, this.imageUrl});
final String? asset;
final String? imageUrl;
}
enum RoomRocketProgressStage {
empty,
charging,
full;
static RoomRocketProgressStage fromPercent(double percent) {
if (percent <= 0) {
return RoomRocketProgressStage.empty;
}
if (percent >= 99.5) {
return RoomRocketProgressStage.full;
}
return RoomRocketProgressStage.charging;
}
}
enum RoomRocketBottomStage { empty, active }
class _RocketCanvas extends StatelessWidget {
const _RocketCanvas({
required this.scale,
required this.percent,
required this.displayPercent,
required this.progressStage,
required this.bottomStage,
required this.status,
required this.round,
required this.levels,
required this.selectedLevel,
required this.selectedLevelItem,
this.onLevelSelected,
required this.rewards,
required this.selectedRewardTab,
this.onRewardTabSelected,
required this.crew,
this.onClose,
this.onOpenRank,
this.onRewardSelected,
this.onOpenRecord,
this.onOpenNote,
});
final double scale;
final double percent;
final int displayPercent;
final RoomRocketProgressStage progressStage;
final RoomRocketBottomStage bottomStage;
final SCRoomRocketStatusRes? status;
final int round;
final List<RoomRocketLevelItem> levels;
final int selectedLevel;
final RoomRocketLevelItem selectedLevelItem;
final ValueChanged<int>? onLevelSelected;
final List<RoomRocketRewardItem> rewards;
final RoomRocketRewardTab selectedRewardTab;
final ValueChanged<RoomRocketRewardTab>? onRewardTabSelected;
final List<RoomRocketCrewMember> crew;
final VoidCallback? onClose;
final VoidCallback? onOpenRank;
final ValueChanged<RoomRocketRewardItem>? onRewardSelected;
final VoidCallback? onOpenRecord;
final VoidCallback? onOpenNote;
double s(double value) => value * scale;
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.hardEdge,
children: [
_image(_RocketAssets.rocketStage, 15, 361, 344, 97),
_buildRocketBody(),
_image(_RocketAssets.sidePanel, 309, 202, 66, 207),
_image(_RocketAssets.sidePanel, 0, 202, 66, 207),
_buildLevelRail(),
_buildProgress(),
_buildSideActions(),
_buildRoundTitle(),
_buildResetTime(),
_buildRewardPanel(),
_buildBottomStrip(),
_buildCloseButton(),
],
);
}
Widget _buildRocketBody() {
final previewImageUrl = selectedLevelItem.previewImageUrl?.trim() ?? '';
return Positioned(
left: s(88),
top: s(111),
width: s(200),
height: s(432),
child: _RocketImage(
imageUrl: previewImageUrl.isEmpty ? null : previewImageUrl,
fit: BoxFit.contain,
preferSvg: true,
pagDisplayScale: 2,
pagClipBehavior: Clip.none,
allowStaticFallback: false,
emptyBuilder: (_) => const SCRotatingDotsLoading(),
debugName:
'centerPreview level=${selectedLevelItem.level} '
'preview=${selectedLevelItem.previewImageUrl ?? '-'}',
),
);
}
Widget _image(
String asset,
double left,
double top,
double width,
double height, {
double opacity = 1,
BoxFit fit = BoxFit.fill,
}) {
return Positioned(
left: s(left),
top: s(top),
width: s(width),
height: s(height),
child: Opacity(
opacity: opacity,
child: Image.asset(asset, fit: fit, gaplessPlayback: true),
),
);
}
Widget _buildRoundTitle() {
return Positioned(
left: 0,
top: s(120),
width: s(_designWidth),
child: _GradientText(
_roomRocketTitleText(level: selectedLevelItem.level, round: round),
fontSize: s(16),
fontWeight: FontWeight.w600,
textAlign: TextAlign.center,
),
);
}
Widget _buildLevelRail() {
return Positioned(
left: s(13),
top: s(212),
width: s(40),
height: s(190),
child: _LevelRail(
scale: scale,
levels: levels,
selectedLevel: selectedLevel,
onLevelSelected: onLevelSelected,
),
);
}
Widget _buildProgress() {
final progressImageUrl = selectedLevelItem.progressImageUrl?.trim() ?? '';
return Stack(
children: [
Positioned(
left: s(320),
top: s(227),
width: s(44),
height: s(134),
child: _RocketProgressBar(
percent: percent,
progressStage: progressStage,
imageUrl: progressImageUrl.isEmpty ? null : progressImageUrl,
),
),
Positioned(
left: s(316),
top: s(373),
width: s(56),
child: _GradientText(
'$displayPercent%',
fontSize: s(16),
fontWeight: FontWeight.w600,
textAlign: TextAlign.center,
colors: const [Color(0xFFA4FFE3), Color(0xFF21C593)],
),
),
],
);
}
Widget _buildSideActions() {
return Stack(
children: [
_image(_RocketAssets.pillBg, 306, 122, 220, 33),
Positioned(
left: s(335),
top: s(127),
width: s(22),
height: s(22),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onOpenNote,
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: const Color(0xFFA4FFE3).withValues(alpha: 0.95),
width: s(1),
),
),
child: Center(
child: Text(
'?',
textScaler: TextScaler.noScaling,
style: TextStyle(
color: Colors.white,
fontSize: s(15),
height: 1,
fontWeight: FontWeight.w700,
decoration: TextDecoration.none,
),
),
),
),
),
),
_image(_RocketAssets.pillBg, 306, 159, 220, 33),
Positioned(
left: s(306),
top: s(159),
width: s(69),
height: s(33),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onOpenRecord,
child: Center(
child: _GradientText(
'Record',
fontSize: s(14),
fontWeight: FontWeight.w600,
textAlign: TextAlign.center,
),
),
),
),
],
);
}
Widget _buildCloseButton() {
return Positioned(
left: s(337),
top: s(94),
width: s(30),
height: s(30),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onClose,
child: Center(
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: const Color(0xFF75F3CB).withValues(alpha: 0.82),
width: s(1),
),
color: const Color(0xFF002F26).withValues(alpha: 0.35),
),
child: SizedBox(
width: s(20),
height: s(20),
child: Icon(
Icons.close_rounded,
size: s(17),
color: const Color(0xFF75F3CB),
),
),
),
),
),
);
}
Widget _buildResetTime() {
return Stack(
children: [
_image(_RocketAssets.pillBg, 78, 458, 220, 33),
Positioned(
left: s(109),
top: s(468),
child: _RocketText(
'Reset Time',
fontSize: s(12),
color: Colors.white,
),
),
Positioned(
left: s(187),
top: s(468),
width: s(88),
child: _ResetCountdownText(status: status, scale: scale),
),
],
);
}
Widget _buildRewardPanel() {
return Stack(
children: [
_image(_RocketAssets.rewardTop, 8, 499, 360, 63),
_image(_RocketAssets.rewardMiddle, 8, 559, 360, 59),
_image(_RocketAssets.rewardMiddle, 8, 594, 360, 59),
_image(_RocketAssets.rewardBottom, 8, 653, 360, 76),
Positioned(
left: 0,
top: s(519),
width: s(_designWidth),
child: const _GradientText(
'Reward',
fontSize: 16,
fontWeight: FontWeight.w600,
textAlign: TextAlign.center,
).scaled(scale),
),
Positioned(
left: s(28),
top: s(543),
width: s(320),
height: math.max(1, s(1)),
child: DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFFB2FFE7).withValues(alpha: 0.16),
),
),
),
_RewardTab(
scale: scale,
left: 31,
top: 552,
label: 'Top1',
active: selectedRewardTab == RoomRocketRewardTab.top1,
onTap: () => onRewardTabSelected?.call(RoomRocketRewardTab.top1),
),
_RewardTab(
scale: scale,
left: 147,
top: 552,
label: 'Set off',
active: selectedRewardTab == RoomRocketRewardTab.setOff,
onTap: () => onRewardTabSelected?.call(RoomRocketRewardTab.setOff),
),
_RewardTab(
scale: scale,
left: 252,
top: 552,
label: 'In the room',
active: selectedRewardTab == RoomRocketRewardTab.inRoom,
onTap: () => onRewardTabSelected?.call(RoomRocketRewardTab.inRoom),
),
Positioned(
left: s(28),
top: s(578),
width: s(336),
height: s(135),
child: ClipRect(
child: ScrollConfiguration(
behavior: const _NoGlowScrollBehavior(),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
child: Row(
children: [
for (var index = 0; index < rewards.length; index++) ...[
SizedBox(
width: s(97),
height: s(135),
child: _RewardCard(
scale: scale,
item: rewards[index],
onTap:
onRewardSelected == null
? null
: () =>
onRewardSelected?.call(rewards[index]),
),
),
if (index != rewards.length - 1) SizedBox(width: s(12)),
],
],
),
),
),
),
),
],
);
}
Widget _buildBottomStrip() {
final activeBottom = bottomStage == RoomRocketBottomStage.active;
final showCrew = activeBottom && crew.isNotEmpty;
return Stack(
children: [
Positioned(
left: s(0),
top: s(729),
width: s(375),
height: s(83),
child: Transform.rotate(
angle: math.pi,
child: Image.asset(
activeBottom
? _RocketAssets.bottomStripActive
: _RocketAssets.bottomStripEmpty,
fit: BoxFit.fill,
gaplessPlayback: true,
),
),
),
_image(_RocketAssets.bottomTrophy, 31, 740, 52, 69),
if (!activeBottom)
Positioned(
left: s(108),
top: s(760),
width: s(169),
height: s(36),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.center,
child: Text(
'Not up to Rocket rank; no\n“Rocket King” title',
textScaler: TextScaler.noScaling,
maxLines: 2,
softWrap: false,
overflow: TextOverflow.visible,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: s(14),
height: 16 / 14,
fontWeight: FontWeight.w400,
decoration: TextDecoration.none,
),
),
),
),
if (showCrew)
for (var index = 0; index < crew.length.clamp(0, 3); index++)
Positioned(
left: s(97 + 71.0 * index),
top: s(753),
width: s(50),
height: s(50),
child: _CrewAvatar(
scale: scale,
member: crew[index],
rank: index,
),
),
Positioned(
left: s(315),
top: s(753),
width: s(52),
height: s(52),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onOpenRank ?? onClose,
child: Center(
child: Image.asset(
_RocketAssets.backArrow,
width: s(32),
height: s(32),
fit: BoxFit.fill,
gaplessPlayback: true,
),
),
),
),
Positioned(
left: s(0),
top: s(729),
width: s(375),
height: s(83),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onOpenRank ?? onClose,
child: const SizedBox.expand(),
),
),
],
);
}
}
class _RocketProgressBar extends StatelessWidget {
const _RocketProgressBar({
required this.percent,
required this.progressStage,
this.imageUrl,
});
final double percent;
final RoomRocketProgressStage progressStage;
final String? imageUrl;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final height = constraints.maxHeight;
final ratio = _progressRatio();
final fillHeight = height * ratio;
final showFillCap =
fillHeight > 0 &&
ratio < 1 &&
progressStage != RoomRocketProgressStage.full;
final capHeight = math.max(1.0, height * 0.014);
return Stack(
fit: StackFit.expand,
children: [
const _RocketImage(
asset: _RocketAssets.progressEmpty,
fit: BoxFit.fill,
),
if (fillHeight > 0)
Positioned(
left: 0,
bottom: 0,
width: width,
height: fillHeight,
child: ClipRect(
child: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
width: width,
height: height,
child: _RocketImage(
asset: _RocketAssets.progressFull,
imageUrl: imageUrl,
fit: BoxFit.fill,
),
),
),
),
),
if (showFillCap)
Positioned(
left: width * 0.08,
bottom: math.max(0, fillHeight - capHeight / 2),
width: width * 0.84,
height: capHeight,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(capHeight),
gradient: LinearGradient(
colors: [
const Color(0xFF30FFC0).withValues(alpha: 0),
const Color(0xFFA4FFE3).withValues(alpha: 0.9),
const Color(0xFF30FFC0).withValues(alpha: 0),
],
),
boxShadow: [
BoxShadow(
color: const Color(0xFF2DFFC1).withValues(alpha: 0.7),
blurRadius: capHeight * 2,
),
],
),
),
),
],
);
},
);
}
double _progressRatio() {
final value = percent.isFinite ? percent : 0.0;
return (value / 100).clamp(0.0, 1.0).toDouble();
}
}
class _RewardCard extends StatelessWidget {
const _RewardCard({required this.scale, required this.item, this.onTap});
final double scale;
final RoomRocketRewardItem item;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Stack(
children: [
Positioned.fill(child: CustomPaint(painter: _RewardCardPainter())),
Positioned(
left: 23 * scale,
top: 24 * scale,
width: 50 * scale,
height: 50 * scale,
child:
item.isGoldReward
? Image.asset(_RocketAssets.coin, fit: BoxFit.contain)
: _RocketImage(
asset: item.asset,
imageUrl: item.imageUrl,
fit: BoxFit.contain,
),
),
Positioned(
left: 16 * scale,
top: 105 * scale,
width: 65 * scale,
child: _RocketText(
item.displayText,
fontSize: 14 * scale,
color: Colors.white,
textAlign: TextAlign.center,
),
),
],
),
);
}
}
class _LevelBadge extends StatelessWidget {
const _LevelBadge({
required this.scale,
required this.item,
required this.selected,
});
final double scale;
final RoomRocketLevelItem item;
final bool selected;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 40 * scale,
height: 52 * scale,
child: Stack(
children: [
Positioned.fill(
child: CustomPaint(
painter: _LevelBadgePainter(
selected: selected,
enabled: item.enabled,
),
),
),
Positioned(
left: 10 * scale,
top: 6 * scale,
width: 20 * scale,
height: 29 * scale,
child: ClipRRect(
borderRadius: BorderRadius.circular(4 * scale),
child:
item.imageUrl != null || item.asset != null
? _RocketImage(
asset: item.asset,
imageUrl: item.imageUrl,
fit: BoxFit.cover,
)
: Stack(
clipBehavior: Clip.hardEdge,
children: [
Positioned(
left: -5.45 * scale,
top: -10.92 * scale,
width: 30.86 * scale,
height: 66.82 * scale,
child: Image.asset(
_RocketAssets.rocketBody,
fit: BoxFit.cover,
gaplessPlayback: true,
),
),
],
),
),
),
Positioned(
left: 0,
right: 0,
top: 38 * scale,
child: _RocketText(
item.label,
fontSize: 10 * scale,
color:
selected
? const Color(0xFFA5FFE3)
: item.enabled
? Colors.white
: Colors.white.withValues(alpha: 0.52),
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
],
),
);
}
}
class _LevelRail extends StatefulWidget {
const _LevelRail({
required this.scale,
required this.levels,
required this.selectedLevel,
this.onLevelSelected,
});
final double scale;
final List<RoomRocketLevelItem> levels;
final int selectedLevel;
final ValueChanged<int>? onLevelSelected;
@override
State<_LevelRail> createState() => _LevelRailState();
}
class _LevelRailState extends State<_LevelRail> {
late final ScrollController _controller;
double get _itemHeight => 52 * widget.scale;
double get _separatorHeight => 4 * widget.scale;
double get _viewportHeight => 190 * widget.scale;
@override
void initState() {
super.initState();
_controller = ScrollController(
initialScrollOffset: _targetOffset(widget.selectedLevel),
);
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToSelected());
}
@override
void didUpdateWidget(covariant _LevelRail oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selectedLevel != widget.selectedLevel ||
oldWidget.levels.length != widget.levels.length ||
oldWidget.scale != widget.scale) {
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToSelected());
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
double _targetOffset(int selectedLevel) {
if (widget.levels.isEmpty) {
return 0;
}
final index = selectedLevel.clamp(0, widget.levels.length - 1).toInt();
final totalHeight =
widget.levels.length * _itemHeight +
math.max(0, widget.levels.length - 1) * _separatorHeight;
final maxOffset = math.max(0, totalHeight - _viewportHeight).toDouble();
final centered =
index * (_itemHeight + _separatorHeight) -
(_viewportHeight - _itemHeight) / 2;
return centered.clamp(0, maxOffset).toDouble();
}
void _scrollToSelected() {
if (!mounted || !_controller.hasClients) {
return;
}
final target = _targetOffset(widget.selectedLevel);
if ((_controller.offset - target).abs() < 0.5) {
return;
}
_controller.animateTo(
target,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
);
}
@override
Widget build(BuildContext context) {
return ScrollConfiguration(
behavior: const _NoGlowScrollBehavior(),
child: ListView.separated(
controller: _controller,
padding: EdgeInsets.zero,
primary: false,
physics: const BouncingScrollPhysics(),
itemCount: widget.levels.length,
separatorBuilder: (_, __) => SizedBox(height: _separatorHeight),
itemBuilder: (context, index) {
final item = widget.levels[index];
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap:
item.enabled ? () => widget.onLevelSelected?.call(index) : null,
child: SizedBox(
width: 40 * widget.scale,
height: _itemHeight,
child: _LevelBadge(
scale: widget.scale,
item: item,
selected: index == widget.selectedLevel,
),
),
);
},
),
);
}
}
class _CrewAvatar extends StatelessWidget {
const _CrewAvatar({
required this.scale,
required this.member,
required this.rank,
});
final double scale;
final RoomRocketCrewMember member;
final int rank;
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned(
left: 6.25 * scale,
top: 5 * scale,
width: 37.5 * scale,
height: 37.5 * scale,
child: ClipOval(child: _avatarImage()),
),
Positioned.fill(child: Image.asset(_frameAsset, fit: BoxFit.contain)),
],
);
}
String get _frameAsset {
return switch (rank) {
0 => _RocketAssets.rankFrame1,
1 => _RocketAssets.rankFrame2,
_ => _RocketAssets.rankFrame3,
};
}
Widget _avatarImage() {
if (member.asset != null || member.imageUrl != null) {
return _RocketImage(
asset: member.asset,
imageUrl: member.imageUrl,
fit: BoxFit.cover,
);
}
return Image.asset(
_RocketAssets.defaultAvatar,
fit: BoxFit.cover,
gaplessPlayback: true,
);
}
}
class _RocketImage extends StatelessWidget {
const _RocketImage({
this.asset,
this.imageUrl,
this.fit = BoxFit.cover,
this.preferSvg = false,
this.pagDisplayScale = 1,
this.pagClipBehavior = Clip.hardEdge,
this.allowStaticFallback = true,
this.emptyBuilder,
this.debugName,
});
final String? asset;
final String? imageUrl;
final BoxFit fit;
final bool preferSvg;
final double pagDisplayScale;
final Clip pagClipBehavior;
final bool allowStaticFallback;
final WidgetBuilder? emptyBuilder;
final String? debugName;
@override
Widget build(BuildContext context) {
if (imageUrl != null && imageUrl!.trim().isNotEmpty) {
final source = imageUrl!.trim();
_debugImage('load ${_sourceKind(source)} ${_shortSource(source)}');
if (SCNetworkSvgaWidget.isSvga(source)) {
return SCNetworkSvgaWidget(
resource: source,
loop: true,
fit: fit,
fallback: _imageFallback(),
);
}
if (RoomRocketPagEffectOverlay.isPag(source)) {
RoomRocketPagEffectOverlay.debugPagResource(
source,
place: debugName ?? 'dialog-image-network',
);
return RoomRocketPagPreview(
resource: source,
loop: true,
fit: fit,
displayScale: pagDisplayScale,
clipBehavior: pagClipBehavior,
underlayBuilder: (_) => _imageFallback(),
defaultBuilder: (_) => const SCRotatingDotsLoading(),
);
}
if (_isInlineSvg(source)) {
return SvgPicture.string(
source,
fit: fit,
errorBuilder: (_, error, __) {
_debugImage('inline svg failed: $error');
return _imageFallback();
},
);
}
if (_isSvg(source) || preferSvg) {
return SvgPicture.network(
source,
fit: fit,
placeholderBuilder: (_) => const SCRotatingDotsLoading(),
errorBuilder: (_, error, __) {
_debugImage('network svg failed: $error');
return preferSvg && !_isSvg(source)
? CustomCachedImage(
imageUrl: source,
fit: fit,
debugName: debugName,
errorWidget:
allowStaticFallback ? null : const SizedBox.shrink(),
)
: _imageFallback();
},
);
}
return CustomCachedImage(
imageUrl: source,
fit: fit,
debugName: debugName,
errorWidget: allowStaticFallback ? null : const SizedBox.shrink(),
);
}
if (allowStaticFallback && asset != null && asset!.trim().isNotEmpty) {
final source = asset!.trim();
_debugImage('load asset ${_shortSource(source)}');
if (SCNetworkSvgaWidget.isSvga(source)) {
return SCNetworkSvgaWidget(
resource: source,
loop: true,
fit: fit,
fallback: _imageFallback(),
);
}
if (RoomRocketPagEffectOverlay.isPag(source)) {
RoomRocketPagEffectOverlay.debugPagResource(
source,
place: debugName ?? 'dialog-image-asset',
);
return RoomRocketPagPreview(
resource: source,
loop: true,
fit: fit,
displayScale: pagDisplayScale,
clipBehavior: pagClipBehavior,
underlayBuilder: (_) => _imageFallback(),
defaultBuilder: (_) => _imageFallback(),
);
}
if (_isSvg(source)) {
return SvgPicture.asset(
source,
fit: fit,
errorBuilder: (_, error, __) {
_debugImage('asset svg failed: $error');
return const SizedBox.shrink();
},
);
}
return Image.asset(source, fit: fit, gaplessPlayback: true);
}
return emptyBuilder?.call(context) ?? const SizedBox.shrink();
}
static bool _isSvg(String value) {
final lower = value.trim().toLowerCase();
return lower.endsWith('.svg') || lower.contains('.svg?');
}
static bool _isInlineSvg(String value) {
final trimmed = value.trimLeft();
return trimmed.startsWith('<svg') ||
(trimmed.startsWith('<?xml') && trimmed.contains('<svg'));
}
static String _sourceKind(String source) {
if (_isInlineSvg(source)) {
return 'inline-svg';
}
if (SCNetworkSvgaWidget.isSvga(source)) {
return 'svga';
}
if (_isSvg(source)) {
return 'svg-url';
}
return 'url';
}
static String _shortSource(String value) {
final text = value.replaceAll(RegExp(r'\s+'), ' ').trim();
if (text.length <= 140) {
return text;
}
return '${text.substring(0, 137)}...';
}
void _debugImage(String message) {
if (!kDebugMode || debugName == null) {
return;
}
debugPrint('[RoomRocketImage] $debugName $message');
}
Widget _imageFallback() {
if (!allowStaticFallback) {
return const SizedBox.shrink();
}
if (asset != null &&
asset!.trim().isNotEmpty &&
!_isSvg(asset!) &&
!RoomRocketPagEffectOverlay.isPag(asset!) &&
!SCNetworkSvgaWidget.isSvga(asset!)) {
return Image.asset(asset!, fit: fit, gaplessPlayback: true);
}
return const SizedBox.shrink();
}
}
class _RewardTab extends StatelessWidget {
const _RewardTab({
required this.scale,
required this.left,
required this.top,
required this.label,
this.active = false,
this.onTap,
});
final double scale;
final double left;
final double top;
final String label;
final bool active;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final color =
active
? const Color(0xFF75F3CB)
: const Color(0xFF75F3CB).withValues(alpha: 0.52);
final width =
label == 'In the room'
? 93.0
: label == 'Set off'
? 76.0
: 68.0;
return Positioned(
left: left * scale,
top: top * scale,
width: width * scale,
height: 14 * scale,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_TabSlash(color: color, scale: scale),
SizedBox(width: 3 * scale),
Flexible(
child: Text(
label,
textScaler: TextScaler.noScaling,
maxLines: 1,
overflow: TextOverflow.visible,
textAlign: TextAlign.center,
style: TextStyle(
color: color,
fontSize: 12 * scale,
height: 1,
decoration: TextDecoration.none,
),
),
),
SizedBox(width: 3 * scale),
Transform.rotate(
angle: math.pi,
child: _TabSlash(color: color, scale: scale),
),
],
),
),
);
}
}
class _TabSlash extends StatelessWidget {
const _TabSlash({required this.color, required this.scale});
final Color color;
final double scale;
@override
Widget build(BuildContext context) {
return Text(
'///',
textScaler: TextScaler.noScaling,
style: TextStyle(
color: color,
fontSize: 10 * scale,
height: 1,
letterSpacing: 0,
decoration: TextDecoration.none,
),
);
}
}
class _NoGlowScrollBehavior extends ScrollBehavior {
const _NoGlowScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context,
Widget child,
ScrollableDetails details,
) {
return child;
}
}
class _LevelBadgePainter extends CustomPainter {
const _LevelBadgePainter({required this.selected, required this.enabled});
final bool selected;
final bool enabled;
@override
void paint(Canvas canvas, Size size) {
final path = _scaledPath(
const [
Offset(35.625, 0.5),
Offset(39.5, 4.39941),
Offset(39.5, 47.5996),
Offset(35.625, 51.5),
Offset(5.18164, 51.5),
Offset(0.5, 47.5732),
Offset(0.5, 4.39941),
Offset(4.375, 0.5),
],
size,
const Size(40, 52),
);
if (selected) {
canvas.drawPath(
path,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = size.width / 7
..maskFilter = MaskFilter.blur(BlurStyle.normal, size.width / 12)
..color = const Color(0xFF56FFD0).withValues(alpha: 0.38),
);
}
canvas.drawPath(
path,
Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors:
selected
? const [Color(0xFF10DFA8), Color(0xFF07392E)]
: const [Color(0xFF077C60), Color(0xFF08251E)],
).createShader(Offset.zero & size),
);
canvas.drawPath(
path,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = selected ? size.width / 14 : size.width / 40
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
selected
? Colors.white
: enabled
? const Color(0xFFB2FBCC)
: const Color(0xFFB2FBCC).withValues(alpha: 0.42),
selected
? const Color(0xFF56FFD0)
: const Color(0xFFB2FBCC).withValues(alpha: 0),
],
).createShader(Offset.zero & size),
);
}
@override
bool shouldRepaint(covariant _LevelBadgePainter oldDelegate) {
return oldDelegate.selected != selected || oldDelegate.enabled != enabled;
}
}
class _RewardCardPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final panelPath = _scaledPath(
const [
Offset(88.293, 0.5),
Offset(96.5, 8.70703),
Offset(96.5, 126.293),
Offset(88.293, 134.5),
Offset(8.70703, 134.5),
Offset(0.5, 126.293),
Offset(0.5, 8.70703),
Offset(8.70703, 0.5),
],
size,
const Size(97, 135),
);
canvas.drawPath(
panelPath,
Paint()
..shader = const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF077C60), Color(0xFF08251E)],
).createShader(Offset.zero & size),
);
canvas.drawPath(
panelPath,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = size.width / 97
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0xFFB2FBCC),
const Color(0xFFB2FBCC).withValues(alpha: 0),
],
).createShader(Offset.zero & size),
);
final pillPath = _scaledPath(
const [
Offset(8, 107.367),
Offset(14.5, 101),
Offset(82.5, 101),
Offset(89, 107.367),
Offset(89, 118.633),
Offset(82.5, 125),
Offset(14.5, 125),
Offset(8, 118.633),
],
size,
const Size(97, 135),
);
canvas.drawPath(
pillPath,
Paint()
..shader = const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0xFF136648), Color(0xFF06412B), Color(0xFF136648)],
).createShader(Offset.zero & size),
);
canvas.drawPath(
pillPath,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = size.width / 97
..color = const Color(0xFF8BF9FE).withValues(alpha: 0.85),
);
final circleCenter = Offset(
size.width * 48.5 / 97,
size.height * 48.5 / 135,
);
final circleRadius = math.min(size.width / 97, size.height / 135) * 37;
canvas.drawCircle(
circleCenter,
circleRadius,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = size.width / 97
..maskFilter = MaskFilter.blur(BlurStyle.normal, size.width / 24)
..color = const Color(0xFFEFFBFF).withValues(alpha: 0.6),
);
canvas.drawCircle(
circleCenter,
circleRadius,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = size.width / 97
..color = const Color(0xFF75F3CB),
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
Path _scaledPath(List<Offset> points, Size size, Size viewBox) {
final path = Path();
for (var index = 0; index < points.length; index++) {
final point = Offset(
points[index].dx / viewBox.width * size.width,
points[index].dy / viewBox.height * size.height,
);
if (index == 0) {
path.moveTo(point.dx, point.dy);
} else {
path.lineTo(point.dx, point.dy);
}
}
return path..close();
}
class _RocketText extends StatelessWidget {
const _RocketText(
this.text, {
required this.fontSize,
required this.color,
this.textAlign = TextAlign.center,
this.fontWeight = FontWeight.w400,
});
final String text;
final double fontSize;
final Color color;
final TextAlign textAlign;
final FontWeight fontWeight;
@override
Widget build(BuildContext context) {
return Text(
text,
textScaler: TextScaler.noScaling,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: textAlign,
style: TextStyle(
color: color,
fontSize: fontSize,
fontWeight: fontWeight,
height: 1,
decoration: TextDecoration.none,
),
);
}
}
class _ResetCountdownText extends StatefulWidget {
const _ResetCountdownText({required this.status, required this.scale});
final SCRoomRocketStatusRes? status;
final double scale;
@override
State<_ResetCountdownText> createState() => _ResetCountdownTextState();
}
class _ResetCountdownTextState extends State<_ResetCountdownText> {
Timer? _timer;
late DateTime _anchor;
late String _sourceKey;
int? _initialRemainingSeconds;
DateTime? _targetTime;
DateTime? _serverTimeAtAnchor;
@override
void initState() {
super.initState();
_resetSource();
_timer = Timer.periodic(
const Duration(seconds: 1),
(_) => mounted ? setState(() {}) : null,
);
}
@override
void didUpdateWidget(covariant _ResetCountdownText oldWidget) {
super.didUpdateWidget(oldWidget);
final nextKey = _countdownSourceKey(widget.status);
if (nextKey != _sourceKey) {
_resetSource();
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _resetSource() {
_anchor = DateTime.now();
_sourceKey = _countdownSourceKey(widget.status);
_initialRemainingSeconds = widget.status?.resetRemainingSeconds;
_targetTime = _targetTimeFromStatus(widget.status);
_serverTimeAtAnchor = _dateTimeFromValue(
widget.status?.serverTime,
_timezoneOffset(widget.status?.timezone),
);
}
@override
Widget build(BuildContext context) {
final parts = _formatDuration(_remainingSeconds());
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_countdownPart(parts[0]),
_separator(),
_countdownPart(parts[1]),
_separator(),
_countdownPart(parts[2]),
],
);
}
Widget _countdownPart(String text) {
return SizedBox(
width: 23 * widget.scale,
child: _RocketText(
text,
fontSize: 12 * widget.scale,
color: const Color(0xFF2DFFC1),
),
);
}
Widget _separator() {
return SizedBox(
width: 7 * widget.scale,
child: _RocketText(
':',
fontSize: 12 * widget.scale,
color: const Color(0xFF2DFFC1),
),
);
}
int _remainingSeconds() {
final elapsed = DateTime.now().difference(_anchor).inSeconds;
final directRemaining = _initialRemainingSeconds;
if (directRemaining != null) {
return math.max(0, directRemaining - elapsed);
}
final target = _targetTime ?? _fallbackResetTarget(widget.status);
final serverTime = _serverTimeAtAnchor;
final now =
serverTime == null
? DateTime.now()
: serverTime.add(Duration(seconds: elapsed));
return math.max(0, target.difference(now).inSeconds);
}
List<String> _formatDuration(int totalSeconds) {
final hours = totalSeconds ~/ 3600;
final minutes = (totalSeconds % 3600) ~/ 60;
final seconds = totalSeconds % 60;
String two(int value) => value.toString().padLeft(2, '0');
return [two(hours), two(minutes), two(seconds)];
}
static String _countdownSourceKey(SCRoomRocketStatusRes? status) {
return [
status?.date,
status?.timezone,
status?.resetRemainingSeconds,
status?.resetTime,
status?.serverTime,
].join('|');
}
static DateTime? _targetTimeFromStatus(SCRoomRocketStatusRes? status) {
return _dateTimeFromValue(
status?.resetTime,
_timezoneOffset(status?.timezone),
);
}
static DateTime _fallbackResetTarget(SCRoomRocketStatusRes? status) {
final offset = _timezoneOffset(status?.timezone);
final dayKey = status?.date?.trim() ?? '';
if (RegExp(r'^\d{8}$').hasMatch(dayKey)) {
final year = int.parse(dayKey.substring(0, 4));
final month = int.parse(dayKey.substring(4, 6));
final day = int.parse(dayKey.substring(6, 8));
return DateTime.utc(year, month, day + 1).subtract(offset).toLocal();
}
final businessNow = DateTime.now().toUtc().add(offset);
return DateTime.utc(
businessNow.year,
businessNow.month,
businessNow.day + 1,
).subtract(offset).toLocal();
}
static DateTime? _dateTimeFromValue(Object? value, Duration offset) {
if (value == null) {
return null;
}
if (value is num) {
return _dateTimeFromTimestamp(value);
}
final text = value.toString().trim();
if (text.isEmpty) {
return null;
}
final numeric = num.tryParse(text);
if (numeric != null) {
return _dateTimeFromTimestamp(numeric);
}
return _dateTimeFromText(text, offset);
}
static DateTime _dateTimeFromTimestamp(num value) {
final milliseconds =
value.abs() < 1000000000000 ? (value * 1000).round() : value.round();
return DateTime.fromMillisecondsSinceEpoch(milliseconds).toLocal();
}
static DateTime? _dateTimeFromText(String text, Duration offset) {
final normalized = text.replaceFirst(' ', 'T');
final hasZone = RegExp(r'(Z|[+-]\d{2}:?\d{2})$').hasMatch(normalized);
if (hasZone) {
return DateTime.tryParse(normalized)?.toLocal();
}
final match = RegExp(
r'^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?',
).firstMatch(text);
if (match == null) {
return DateTime.tryParse(normalized)?.toLocal();
}
final year = int.parse(match.group(1)!);
final month = int.parse(match.group(2)!);
final day = int.parse(match.group(3)!);
final hour = int.parse(match.group(4)!);
final minute = int.parse(match.group(5)!);
final second = int.tryParse(match.group(6) ?? '0') ?? 0;
return DateTime.utc(
year,
month,
day,
hour,
minute,
second,
).subtract(offset).toLocal();
}
static Duration _timezoneOffset(String? timezone) {
final value = timezone?.trim();
if (value == null || value.isEmpty) {
return const Duration(hours: 3);
}
final normalized = value.toUpperCase();
final match = RegExp(
r'^(?:UTC|GMT)?([+-])(\d{1,2})(?::?(\d{2}))?$',
).firstMatch(normalized);
if (match != null) {
final sign = match.group(1) == '-' ? -1 : 1;
final hours = int.parse(match.group(2)!);
final minutes = int.tryParse(match.group(3) ?? '0') ?? 0;
return Duration(hours: sign * hours, minutes: sign * minutes);
}
switch (value) {
case 'Asia/Riyadh':
case 'Asia/Kuwait':
case 'Asia/Qatar':
case 'Asia/Bahrain':
case 'Asia/Baghdad':
case 'Europe/Moscow':
return const Duration(hours: 3);
case 'Asia/Dubai':
case 'Asia/Muscat':
return const Duration(hours: 4);
case 'Asia/Shanghai':
case 'Asia/Hong_Kong':
case 'Asia/Singapore':
return const Duration(hours: 8);
default:
return const Duration(hours: 3);
}
}
}
class _GradientText extends StatelessWidget {
const _GradientText(
this.text, {
required this.fontSize,
this.fontWeight = FontWeight.w400,
this.textAlign = TextAlign.left,
this.colors = const [Colors.white, Color(0xFFA4FFE3)],
});
final String text;
final double fontSize;
final FontWeight fontWeight;
final TextAlign textAlign;
final List<Color> colors;
_GradientText scaled(double scale) {
return _GradientText(
text,
fontSize: fontSize * scale,
fontWeight: fontWeight,
textAlign: textAlign,
colors: colors,
);
}
@override
Widget build(BuildContext context) {
return ShaderMask(
shaderCallback:
(bounds) => LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: colors,
).createShader(bounds),
blendMode: BlendMode.srcIn,
child: Text(
text,
textScaler: TextScaler.noScaling,
maxLines: 1,
textAlign: textAlign,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: fontSize,
height: 1,
fontWeight: fontWeight,
decoration: TextDecoration.none,
),
),
);
}
}
class _RocketAssets {
const _RocketAssets._();
static const String rocketStage = 'sc_images/room/rocket/rocket_stage.png';
static const String rocketBody = 'sc_images/room/rocket/rocket_body.png';
static const String sidePanel = 'sc_images/room/rocket/side_panel.png';
static const String bottomStripActive =
'sc_images/room/rocket/bottom_strip_active.png';
static const String bottomStripEmpty =
'sc_images/room/rocket/bottom_strip_active.png';
static const String bottomTrophy = 'sc_images/room/rocket/bottom_trophy.png';
static const String backArrow = 'sc_images/room/rocket/back_arrow.png';
static const String rankFrame1 =
'sc_images/room/rocket/rocket_rank_frame_1.png';
static const String rankFrame2 =
'sc_images/room/rocket/rocket_rank_frame_2.png';
static const String rankFrame3 =
'sc_images/room/rocket/rocket_rank_frame_3.png';
static const String pillBg = 'sc_images/room/rocket/pill_bg.png';
static const String progressFull = 'sc_images/room/rocket/progress_full.png';
static const String progressEmpty =
'sc_images/room/rocket/progress_empty.png';
static const String rewardTop = 'sc_images/room/rocket/reward_dialog_top.png';
static const String rewardMiddle =
'sc_images/room/rocket/reward_dialog_middle.png';
static const String rewardBottom =
'sc_images/room/rocket/reward_dialog_bottom.png';
static const String rewardThumb = 'sc_images/room/rocket/reward_thumb.png';
static const String defaultAvatar =
'sc_images/general/sc_icon_avar_defalt.png';
static const String coin = 'sc_images/register_reward/yumi_coin.png';
}
const double _designWidth = 375;
const double _designHeight = 812;