1098 lines
29 KiB
Dart
1098 lines
29 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:yumi/services/audio/rtc_manager.dart';
|
|
import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_api_res.dart';
|
|
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
|
|
import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart';
|
|
|
|
class RoomRocketRankPage extends StatefulWidget {
|
|
const RoomRocketRankPage({
|
|
super.key,
|
|
this.roomId,
|
|
this.showRound2Today = true,
|
|
this.levelRanks,
|
|
this.currentUser,
|
|
this.initialLevel = 0,
|
|
});
|
|
|
|
final String? roomId;
|
|
final bool showRound2Today;
|
|
final List<List<RoomRocketRankEntry>>? levelRanks;
|
|
final RoomRocketRankEntry? currentUser;
|
|
final int initialLevel;
|
|
|
|
@override
|
|
State<RoomRocketRankPage> createState() => _RoomRocketRankPageState();
|
|
}
|
|
|
|
class RoomRocketRankEntry {
|
|
const RoomRocketRankEntry({
|
|
required this.rank,
|
|
required this.nickname,
|
|
this.userId,
|
|
this.account,
|
|
this.avatarUrl,
|
|
this.avatarAsset,
|
|
});
|
|
|
|
final int rank;
|
|
final String nickname;
|
|
final String? userId;
|
|
final String? account;
|
|
final String? avatarUrl;
|
|
final String? avatarAsset;
|
|
|
|
RoomRocketRankEntry copyWith({
|
|
int? rank,
|
|
String? nickname,
|
|
String? userId,
|
|
String? account,
|
|
String? avatarUrl,
|
|
String? avatarAsset,
|
|
}) {
|
|
return RoomRocketRankEntry(
|
|
rank: rank ?? this.rank,
|
|
nickname: nickname ?? this.nickname,
|
|
userId: userId ?? this.userId,
|
|
account: account ?? this.account,
|
|
avatarUrl: avatarUrl ?? this.avatarUrl,
|
|
avatarAsset: avatarAsset ?? this.avatarAsset,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RoomRocketRankPageState extends State<RoomRocketRankPage> {
|
|
late int _selectedLevel;
|
|
final Map<int, List<RoomRocketRankEntry>> _loadedRanks = {};
|
|
final Map<int, int> _loadedRoundNos = {};
|
|
final Map<int, RoomRocketRankEntry> _loadedCurrentUsers = {};
|
|
final Set<int> _loadingLevels = {};
|
|
|
|
static const int _levelCount = 6;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_selectedLevel = widget.initialLevel.clamp(0, _levelCount - 1);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
_loadLevel(_selectedLevel);
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
|
value: const SystemUiOverlayStyle(
|
|
statusBarColor: Colors.transparent,
|
|
statusBarIconBrightness: Brightness.light,
|
|
statusBarBrightness: Brightness.dark,
|
|
systemNavigationBarColor: Color(0xFF010200),
|
|
systemNavigationBarIconBrightness: Brightness.light,
|
|
),
|
|
child: Scaffold(
|
|
backgroundColor: const Color(0xFF010200),
|
|
extendBodyBehindAppBar: true,
|
|
body: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
return SizedBox.expand(
|
|
child: FittedBox(
|
|
fit: BoxFit.contain,
|
|
child: SizedBox(
|
|
width: _RocketRankLayout.designWidth,
|
|
height: _RocketRankLayout.designHeight,
|
|
child: _RocketRankCanvas(
|
|
selectedLevel: _selectedLevel,
|
|
showRound2Today: widget.showRound2Today,
|
|
roundNo: _loadedRoundNos[_selectedLevel] ?? 0,
|
|
ranksByLevel: _normalizedRanks,
|
|
currentUser:
|
|
widget.currentUser ??
|
|
_currentUserEntryForLevel(_selectedLevel),
|
|
onLevelChanged: (index) {
|
|
setState(() => _selectedLevel = index);
|
|
_loadLevel(index);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<List<RoomRocketRankEntry>> get _normalizedRanks {
|
|
final providedRanks = widget.levelRanks;
|
|
if (providedRanks == null) {
|
|
final roomId = _currentRoomId();
|
|
if (roomId.isEmpty) {
|
|
return List<List<RoomRocketRankEntry>>.generate(
|
|
_levelCount,
|
|
(_) => const <RoomRocketRankEntry>[],
|
|
);
|
|
}
|
|
return List<List<RoomRocketRankEntry>>.generate(_levelCount, (index) {
|
|
return _loadedRanks[index] ?? const <RoomRocketRankEntry>[];
|
|
});
|
|
}
|
|
return List<List<RoomRocketRankEntry>>.generate(_levelCount, (index) {
|
|
if (index >= providedRanks.length) {
|
|
return const <RoomRocketRankEntry>[];
|
|
}
|
|
return providedRanks[index];
|
|
});
|
|
}
|
|
|
|
String _currentRoomId() {
|
|
final propRoomId = widget.roomId?.trim() ?? '';
|
|
if (propRoomId.isNotEmpty) {
|
|
return propRoomId;
|
|
}
|
|
try {
|
|
return Provider.of<RtcProvider>(
|
|
context,
|
|
listen: false,
|
|
).currenRoom?.roomProfile?.roomProfile?.id?.trim() ??
|
|
'';
|
|
} catch (_) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
void _loadLevel(int index) {
|
|
if (widget.levelRanks != null || _loadingLevels.contains(index)) {
|
|
_rocketRankDebug(
|
|
'skip king request level=${index + 1} provided=${widget.levelRanks != null} loading=${_loadingLevels.contains(index)}',
|
|
);
|
|
return;
|
|
}
|
|
final roomId = _currentRoomId();
|
|
if (roomId.isEmpty) {
|
|
_rocketRankDebug('skip king request because roomId is empty');
|
|
return;
|
|
}
|
|
_loadingLevels.add(index);
|
|
_rocketRankDebug('request king roomId=$roomId level=${index + 1}');
|
|
SCChatRoomRepository()
|
|
.roomRocketKing(roomId, level: index + 1)
|
|
.then((res) {
|
|
if (!mounted || roomId != _currentRoomId()) {
|
|
return;
|
|
}
|
|
final entries = _rankEntriesFromKing(res);
|
|
final currentUser = _currentUserEntryFromKing(res, entries);
|
|
setState(() {
|
|
_loadedRanks[index] = entries;
|
|
_loadedCurrentUsers[index] = currentUser;
|
|
_loadedRoundNos[index] =
|
|
res.sourceRoundNo > 0 ? res.sourceRoundNo : res.roundNo;
|
|
});
|
|
})
|
|
.catchError((_) {
|
|
_rocketRankDebug('king request failed level=${index + 1}');
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_loadedRanks[index] = const <RoomRocketRankEntry>[];
|
|
});
|
|
})
|
|
.whenComplete(() {
|
|
_loadingLevels.remove(index);
|
|
});
|
|
}
|
|
|
|
List<RoomRocketRankEntry> _rankEntriesFromKing(SCRoomRocketKingRes king) {
|
|
return king.displayRecords
|
|
.map(
|
|
(item) => RoomRocketRankEntry(
|
|
rank: item.rank,
|
|
nickname: item.displayName,
|
|
userId: item.userId,
|
|
account: item.account,
|
|
avatarUrl: item.userAvatar.isEmpty ? null : item.userAvatar,
|
|
),
|
|
)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
RoomRocketRankEntry _currentUserEntryFromKing(
|
|
SCRoomRocketKingRes king,
|
|
List<RoomRocketRankEntry> entries,
|
|
) {
|
|
final apiCurrentUser = king.currentUser;
|
|
if (apiCurrentUser != null) {
|
|
return _rankEntryFromKingUser(apiCurrentUser);
|
|
}
|
|
final matched = _findCurrentUserInEntries(entries);
|
|
if (matched != null) {
|
|
return matched;
|
|
}
|
|
return _currentAccountEntry(rank: 0);
|
|
}
|
|
|
|
RoomRocketRankEntry _currentUserEntryForLevel(int levelIndex) {
|
|
final loadedCurrentUser = _loadedCurrentUsers[levelIndex];
|
|
if (loadedCurrentUser != null) {
|
|
return loadedCurrentUser;
|
|
}
|
|
final ranks =
|
|
levelIndex < _normalizedRanks.length
|
|
? _normalizedRanks[levelIndex]
|
|
: const <RoomRocketRankEntry>[];
|
|
final matched = _findCurrentUserInEntries(ranks);
|
|
if (matched != null) {
|
|
return matched;
|
|
}
|
|
return _currentAccountEntry(rank: 0);
|
|
}
|
|
|
|
RoomRocketRankEntry? _findCurrentUserInEntries(
|
|
List<RoomRocketRankEntry> entries,
|
|
) {
|
|
final currentProfile = AccountStorage().getCurrentUser()?.userProfile;
|
|
final currentUserId = (currentProfile?.id ?? '').trim();
|
|
final currentAccount = (currentProfile?.account ?? '').trim();
|
|
for (final entry in entries) {
|
|
final entryUserId = (entry.userId ?? '').trim();
|
|
final entryAccount = (entry.account ?? '').trim();
|
|
if (currentUserId.isNotEmpty && entryUserId == currentUserId) {
|
|
return entry;
|
|
}
|
|
if (currentAccount.isNotEmpty && entryAccount == currentAccount) {
|
|
return entry;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
RoomRocketRankEntry _rankEntryFromKingUser(SCRoomRocketKingUserRes user) {
|
|
return RoomRocketRankEntry(
|
|
rank: user.rank,
|
|
nickname: user.displayName.isNotEmpty ? user.displayName : 'You',
|
|
userId: user.userId,
|
|
account: user.account,
|
|
avatarUrl: user.userAvatar.isEmpty ? null : user.userAvatar,
|
|
);
|
|
}
|
|
|
|
RoomRocketRankEntry _currentAccountEntry({required int rank}) {
|
|
final currentProfile = AccountStorage().getCurrentUser()?.userProfile;
|
|
final nicknameCandidates = [
|
|
currentProfile?.userNickname,
|
|
currentProfile?.account,
|
|
currentProfile?.id,
|
|
];
|
|
final nickname = nicknameCandidates
|
|
.map((item) => item?.trim() ?? '')
|
|
.firstWhere((item) => item.isNotEmpty, orElse: () => 'You');
|
|
final avatarUrl = currentProfile?.userAvatar?.trim() ?? '';
|
|
return RoomRocketRankEntry(
|
|
rank: rank,
|
|
nickname: nickname,
|
|
userId: currentProfile?.id,
|
|
account: currentProfile?.account,
|
|
avatarUrl: avatarUrl.isEmpty ? null : avatarUrl,
|
|
);
|
|
}
|
|
}
|
|
|
|
void _rocketRankDebug(String message) {
|
|
if (kDebugMode) {
|
|
debugPrint('[RoomRocketRank] $message');
|
|
}
|
|
}
|
|
|
|
class _RocketRankCanvas extends StatelessWidget {
|
|
const _RocketRankCanvas({
|
|
required this.selectedLevel,
|
|
required this.showRound2Today,
|
|
required this.roundNo,
|
|
required this.ranksByLevel,
|
|
required this.currentUser,
|
|
required this.onLevelChanged,
|
|
});
|
|
|
|
final int selectedLevel;
|
|
final bool showRound2Today;
|
|
final int roundNo;
|
|
final List<List<RoomRocketRankEntry>> ranksByLevel;
|
|
final RoomRocketRankEntry currentUser;
|
|
final ValueChanged<int> onLevelChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final entries =
|
|
selectedLevel < ranksByLevel.length
|
|
? ranksByLevel[selectedLevel]
|
|
: const <RoomRocketRankEntry>[];
|
|
|
|
return DecoratedBox(
|
|
decoration: const BoxDecoration(color: Color(0xFF010200)),
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
const _RocketRankBackground(),
|
|
const _RocketRankNavigation(),
|
|
Positioned(
|
|
left: 0,
|
|
top: 100,
|
|
width: _RocketRankLayout.designWidth,
|
|
child: Visibility(
|
|
visible: showRound2Today && roundNo > 1,
|
|
child: _GradientText('Round ${roundNo > 0 ? roundNo : 1} Today'),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 0,
|
|
top: 124,
|
|
width: _RocketRankLayout.designWidth,
|
|
height: 22,
|
|
child: _RocketLevelTabs(
|
|
selectedIndex: selectedLevel,
|
|
onChanged: onLevelChanged,
|
|
),
|
|
),
|
|
if (entries.isEmpty)
|
|
const _RocketRankEmptyState()
|
|
else
|
|
_RocketRankListContent(entries: entries),
|
|
_RocketRankBottomBar(entry: currentUser),
|
|
const _RocketRankBackButton(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RocketRankBackground extends StatelessWidget {
|
|
const _RocketRankBackground();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Stack(
|
|
children: [
|
|
Positioned(
|
|
left: 0,
|
|
top: -1,
|
|
width: _RocketRankLayout.designWidth,
|
|
height: 665,
|
|
child: Opacity(
|
|
opacity: 0.24,
|
|
child: Transform.rotate(
|
|
angle: math.pi,
|
|
child: Image.asset(
|
|
_RocketRankAssets.background,
|
|
fit: BoxFit.cover,
|
|
filterQuality: FilterQuality.high,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RocketRankNavigation extends StatelessWidget {
|
|
const _RocketRankNavigation();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return const Stack(
|
|
children: [
|
|
Positioned(
|
|
left: 0,
|
|
top: 57,
|
|
width: _RocketRankLayout.designWidth,
|
|
child: Text(
|
|
'Rocket King',
|
|
textAlign: TextAlign.center,
|
|
textScaler: TextScaler.noScaling,
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 18,
|
|
height: 1,
|
|
fontWeight: FontWeight.w500,
|
|
decoration: TextDecoration.none,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RocketRankBackButton extends StatelessWidget {
|
|
const _RocketRankBackButton();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Positioned(
|
|
left: 0,
|
|
top: 46,
|
|
width: 56,
|
|
height: 44,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () {
|
|
final navigator = Navigator.of(context);
|
|
if (navigator.canPop()) {
|
|
navigator.pop();
|
|
}
|
|
},
|
|
child: const Align(
|
|
alignment: Alignment.center,
|
|
child: Icon(
|
|
Icons.arrow_back_ios_new_rounded,
|
|
color: Colors.white,
|
|
size: 24,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GradientText extends StatelessWidget {
|
|
const _GradientText(this.text);
|
|
|
|
final String text;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ShaderMask(
|
|
shaderCallback:
|
|
(bounds) => const LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [Colors.white, Color(0xFFA4FFE3)],
|
|
).createShader(bounds),
|
|
blendMode: BlendMode.srcIn,
|
|
child: Text(
|
|
text,
|
|
textAlign: TextAlign.center,
|
|
textScaler: TextScaler.noScaling,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 16,
|
|
height: 1,
|
|
fontWeight: FontWeight.w500,
|
|
decoration: TextDecoration.none,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RocketLevelTabs extends StatelessWidget {
|
|
const _RocketLevelTabs({
|
|
required this.selectedIndex,
|
|
required this.onChanged,
|
|
});
|
|
|
|
final int selectedIndex;
|
|
final ValueChanged<int> onChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
physics: const BouncingScrollPhysics(),
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(left: 8, right: 16),
|
|
child: Row(
|
|
children: List.generate(_RoomRocketRankPageState._levelCount, (
|
|
index,
|
|
) {
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
right:
|
|
index == _RoomRocketRankPageState._levelCount - 1 ? 0 : 7,
|
|
),
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () => onChanged(index),
|
|
child: SizedBox(
|
|
width: 54,
|
|
height: 22,
|
|
child: _RocketLevelTab(
|
|
label: 'Lv.${index + 1}',
|
|
selected: index == selectedIndex,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RocketLevelTab extends StatelessWidget {
|
|
const _RocketLevelTab({required this.label, required this.selected});
|
|
|
|
final String label;
|
|
final bool selected;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final color =
|
|
selected
|
|
? const Color(0xFF75F3CB)
|
|
: Colors.white.withValues(alpha: 0.52);
|
|
return Stack(
|
|
clipBehavior: Clip.none,
|
|
alignment: Alignment.center,
|
|
children: [
|
|
if (selected)
|
|
const Positioned(left: 0, top: 8, child: _LevelSlashGroup()),
|
|
if (selected)
|
|
const Positioned(
|
|
right: 0,
|
|
top: 8,
|
|
child: _LevelSlashGroup(flipped: true),
|
|
),
|
|
Text(
|
|
label,
|
|
textAlign: TextAlign.center,
|
|
textScaler: TextScaler.noScaling,
|
|
style: TextStyle(
|
|
color: color,
|
|
fontSize: 12,
|
|
height: 1,
|
|
fontWeight: FontWeight.w400,
|
|
decoration: TextDecoration.none,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LevelSlashGroup extends StatelessWidget {
|
|
const _LevelSlashGroup({this.flipped = false});
|
|
|
|
final bool flipped;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final painter = _LevelSlashPainter(flipped: flipped);
|
|
return CustomPaint(size: const Size(12, 6), painter: painter);
|
|
}
|
|
}
|
|
|
|
class _LevelSlashPainter extends CustomPainter {
|
|
const _LevelSlashPainter({required this.flipped});
|
|
|
|
final bool flipped;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint =
|
|
Paint()
|
|
..color = const Color(0xFF75F3CB)
|
|
..strokeWidth = 1.3
|
|
..strokeCap = StrokeCap.round;
|
|
for (var i = 0; i < 3; i++) {
|
|
final x = i * 3.2 + 1;
|
|
final start =
|
|
flipped ? Offset(size.width - x, 1) : Offset(x, size.height - 1);
|
|
final end =
|
|
flipped ? Offset(size.width - x - 2.2, 5) : Offset(x + 2.2, 1);
|
|
canvas.drawLine(start, end, paint);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _LevelSlashPainter oldDelegate) {
|
|
return oldDelegate.flipped != flipped;
|
|
}
|
|
}
|
|
|
|
class _RocketRankListContent extends StatelessWidget {
|
|
const _RocketRankListContent({required this.entries});
|
|
|
|
final List<RoomRocketRankEntry> entries;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final topEntries = entries.take(3).toList();
|
|
final listEntries = entries.skip(3).toList();
|
|
return Stack(
|
|
children: [
|
|
if (topEntries.isNotEmpty) _RankOne(entry: topEntries[0]),
|
|
if (topEntries.length > 1)
|
|
_SideRank(
|
|
entry: topEntries[1],
|
|
left: 34,
|
|
avatarLeft: 53,
|
|
frameAsset: _RocketRankAssets.rankTwoFrame,
|
|
),
|
|
if (topEntries.length > 2)
|
|
_SideRank(
|
|
entry: topEntries[2],
|
|
left: 231,
|
|
avatarLeft: 250,
|
|
frameAsset: _RocketRankAssets.rankThreeFrame,
|
|
),
|
|
Positioned(
|
|
left: 11,
|
|
top: 405,
|
|
width: 353,
|
|
height: 324,
|
|
child: MediaQuery.removePadding(
|
|
context: context,
|
|
removeTop: true,
|
|
removeBottom: true,
|
|
child: ScrollConfiguration(
|
|
behavior: const _NoGlowScrollBehavior(),
|
|
child: ListView.builder(
|
|
padding: EdgeInsets.zero,
|
|
physics: const BouncingScrollPhysics(),
|
|
itemExtent: 47,
|
|
itemCount: listEntries.length,
|
|
itemBuilder: (context, index) {
|
|
return _RocketRankRow(entry: listEntries[index]);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RankOne extends StatelessWidget {
|
|
const _RankOne({required this.entry});
|
|
|
|
final RoomRocketRankEntry entry;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Stack(
|
|
children: [
|
|
Positioned(
|
|
left: 106,
|
|
top: 219,
|
|
width: 162,
|
|
height: 46,
|
|
child: Image.asset(
|
|
_RocketRankAssets.podium,
|
|
fit: BoxFit.cover,
|
|
filterQuality: FilterQuality.high,
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 143,
|
|
top: 157,
|
|
width: 88,
|
|
height: 88,
|
|
child: _FramedRankAvatar(
|
|
entry: entry,
|
|
frameAsset: _RocketRankAssets.rankOneFrame,
|
|
imageInset: 8.8,
|
|
imageSize: 70.4,
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 82,
|
|
top: 267,
|
|
width: 211,
|
|
child: _RocketSingleLineText(
|
|
entry.nickname,
|
|
fontSize: 16,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SideRank extends StatelessWidget {
|
|
const _SideRank({
|
|
required this.entry,
|
|
required this.left,
|
|
required this.avatarLeft,
|
|
required this.frameAsset,
|
|
});
|
|
|
|
final RoomRocketRankEntry entry;
|
|
final double left;
|
|
final double avatarLeft;
|
|
final String frameAsset;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Stack(
|
|
children: [
|
|
Positioned(
|
|
left: left,
|
|
top: 324,
|
|
width: 110,
|
|
height: 31,
|
|
child: Opacity(
|
|
opacity: 0.65,
|
|
child: Image.asset(
|
|
_RocketRankAssets.podium,
|
|
fit: BoxFit.cover,
|
|
filterQuality: FilterQuality.high,
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: avatarLeft,
|
|
top: 272,
|
|
width: 72,
|
|
height: 72,
|
|
child: _FramedRankAvatar(
|
|
entry: entry,
|
|
frameAsset: frameAsset,
|
|
imageInset: 7.2,
|
|
imageSize: 57.6,
|
|
),
|
|
),
|
|
Positioned(
|
|
left: left,
|
|
top: 359,
|
|
width: 110,
|
|
child: _RocketSingleLineText(
|
|
entry.nickname,
|
|
fontSize: 16,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FramedRankAvatar extends StatelessWidget {
|
|
const _FramedRankAvatar({
|
|
required this.entry,
|
|
required this.frameAsset,
|
|
required this.imageInset,
|
|
required this.imageSize,
|
|
});
|
|
|
|
final RoomRocketRankEntry entry;
|
|
final String frameAsset;
|
|
final double imageInset;
|
|
final double imageSize;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Stack(
|
|
children: [
|
|
Positioned(
|
|
left: imageInset,
|
|
top: imageInset,
|
|
width: imageSize,
|
|
height: imageSize,
|
|
child: _CircularAvatar(entry: entry),
|
|
),
|
|
Positioned.fill(
|
|
child: Image.asset(
|
|
frameAsset,
|
|
fit: BoxFit.contain,
|
|
filterQuality: FilterQuality.high,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RocketRankRow extends StatelessWidget {
|
|
const _RocketRankRow({required this.entry});
|
|
|
|
final RoomRocketRankEntry entry;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
width: 353,
|
|
height: 47,
|
|
child: Stack(
|
|
children: [
|
|
Positioned(
|
|
left: 14,
|
|
top: 7,
|
|
width: 18,
|
|
child: _RocketSingleLineText(
|
|
'${entry.rank}',
|
|
fontSize: 14,
|
|
textAlign: TextAlign.left,
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 42,
|
|
top: 0,
|
|
width: 30,
|
|
height: 30,
|
|
child: _CircularAvatar(entry: entry),
|
|
),
|
|
Positioned(
|
|
left: 82,
|
|
top: 7,
|
|
width: 170,
|
|
child: _RocketSingleLineText(
|
|
entry.nickname,
|
|
fontSize: 14,
|
|
textAlign: TextAlign.left,
|
|
),
|
|
),
|
|
const Positioned(
|
|
left: 0,
|
|
top: 38,
|
|
width: 353,
|
|
height: 1,
|
|
child: _RocketRankDivider(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RocketRankBottomBar extends StatelessWidget {
|
|
const _RocketRankBottomBar({required this.entry});
|
|
|
|
final RoomRocketRankEntry entry;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Stack(
|
|
children: [
|
|
Positioned(
|
|
left: 0,
|
|
top: 729,
|
|
width: _RocketRankLayout.designWidth,
|
|
height: 83,
|
|
child: Transform.rotate(
|
|
angle: math.pi,
|
|
child: Image.asset(
|
|
_RocketRankAssets.bottomBar,
|
|
fit: BoxFit.cover,
|
|
filterQuality: FilterQuality.high,
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 36,
|
|
top: 768,
|
|
width: 32,
|
|
child: _RocketSingleLineText(
|
|
entry.rank > 0 ? '${entry.rank}' : '--',
|
|
fontSize: 16,
|
|
textAlign: TextAlign.left,
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 75,
|
|
top: 758,
|
|
width: 38,
|
|
height: 38,
|
|
child: _CircularAvatar(
|
|
entry: RoomRocketRankEntry(
|
|
rank: entry.rank,
|
|
nickname: entry.nickname,
|
|
userId: entry.userId,
|
|
account: entry.account,
|
|
avatarUrl: entry.avatarUrl,
|
|
avatarAsset: entry.avatarAsset,
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 122,
|
|
top: 768,
|
|
width: 190,
|
|
child: _RocketSingleLineText(
|
|
entry.nickname,
|
|
fontSize: 16,
|
|
textAlign: TextAlign.left,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RocketRankEmptyState extends StatelessWidget {
|
|
const _RocketRankEmptyState();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Positioned(
|
|
left: 28,
|
|
top: 208,
|
|
width: 319,
|
|
height: 430,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Image.asset(
|
|
_RocketRankAssets.noData,
|
|
width: 118,
|
|
height: 118,
|
|
fit: BoxFit.contain,
|
|
),
|
|
const SizedBox(height: 14),
|
|
Text(
|
|
'Not rocket-level; no “Rocket King”',
|
|
textAlign: TextAlign.center,
|
|
textScaler: TextScaler.noScaling,
|
|
style: TextStyle(
|
|
color: Colors.white.withValues(alpha: 0.62),
|
|
fontSize: 14,
|
|
height: 1.25,
|
|
fontWeight: FontWeight.w400,
|
|
decoration: TextDecoration.none,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CircularAvatar extends StatelessWidget {
|
|
const _CircularAvatar({required this.entry});
|
|
|
|
final RoomRocketRankEntry entry;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final avatarUrl = entry.avatarUrl?.trim() ?? '';
|
|
final avatarAsset = entry.avatarAsset?.trim() ?? '';
|
|
final image =
|
|
avatarUrl.isNotEmpty
|
|
? Image.network(
|
|
avatarUrl,
|
|
fit: BoxFit.cover,
|
|
filterQuality: FilterQuality.high,
|
|
errorBuilder: (_, _, _) => const _AvatarPlaceholder(),
|
|
)
|
|
: avatarAsset.isNotEmpty
|
|
? Image.asset(avatarAsset, fit: BoxFit.cover)
|
|
: const _AvatarPlaceholder();
|
|
return ClipOval(child: image);
|
|
}
|
|
}
|
|
|
|
class _AvatarPlaceholder extends StatelessWidget {
|
|
const _AvatarPlaceholder();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return const DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [Color(0xFF0F6B55), Color(0xFF031F19)],
|
|
),
|
|
),
|
|
child: Icon(Icons.person, color: Color(0xFF8CFFE3), size: 18),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RocketSingleLineText extends StatelessWidget {
|
|
const _RocketSingleLineText(
|
|
this.text, {
|
|
required this.fontSize,
|
|
required this.textAlign,
|
|
});
|
|
|
|
final String text;
|
|
final double fontSize;
|
|
final TextAlign textAlign;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Text(
|
|
text,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: textAlign,
|
|
textScaler: TextScaler.noScaling,
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: fontSize,
|
|
height: 1,
|
|
fontWeight: FontWeight.w400,
|
|
decoration: TextDecoration.none,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RocketRankDivider extends StatelessWidget {
|
|
const _RocketRankDivider();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: [
|
|
Colors.white.withValues(alpha: 0),
|
|
Colors.white.withValues(alpha: 0.28),
|
|
Colors.white.withValues(alpha: 0),
|
|
],
|
|
stops: const [0, 0.5, 1],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _NoGlowScrollBehavior extends ScrollBehavior {
|
|
const _NoGlowScrollBehavior();
|
|
|
|
@override
|
|
Widget buildOverscrollIndicator(
|
|
BuildContext context,
|
|
Widget child,
|
|
ScrollableDetails details,
|
|
) {
|
|
return child;
|
|
}
|
|
}
|
|
|
|
class _RocketRankLayout {
|
|
const _RocketRankLayout._();
|
|
|
|
static const double designWidth = 375;
|
|
static const double designHeight = 812;
|
|
}
|
|
|
|
class _RocketRankAssets {
|
|
const _RocketRankAssets._();
|
|
|
|
static const String background = 'sc_images/room/rocket/rocket_rank_bg.png';
|
|
static const String bottomBar =
|
|
'sc_images/room/rocket/bottom_strip_active.png';
|
|
static const String podium = 'sc_images/room/rocket/rocket_stage.png';
|
|
static const String rankOneFrame =
|
|
'sc_images/room/rocket/rocket_rank_frame_1.png';
|
|
static const String rankTwoFrame =
|
|
'sc_images/room/rocket/rocket_rank_frame_2.png';
|
|
static const String rankThreeFrame =
|
|
'sc_images/room/rocket/rocket_rank_frame_3.png';
|
|
static const String noData = 'sc_images/general/sc_no_data.png';
|
|
}
|