2110 lines
59 KiB
Dart
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;
|