234 lines
6.6 KiB
Dart
234 lines
6.6 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../../../core/theme/tokens.dart';
|
|
import '../../../core/ui/relative_time.dart';
|
|
import '../campaign_data.dart';
|
|
import 'campaign_style.dart';
|
|
|
|
/// 기여 랭킹 조각 — 시안 `333:70971`(사용자 랭킹) · 캠페인 상세 「실시간 랭킹」 공용.
|
|
|
|
/// 랭킹 데이터에 사진 필드가 없어 시안 목록의 단색 원을 순위 순서대로 돌려 쓴다.
|
|
const _avatarColors = [
|
|
Color(0xFF000000),
|
|
Color(0xFF7358E9),
|
|
Color(0xFFFA46E8),
|
|
Color(0xFFC6D284),
|
|
Color(0xFFF4CFFA),
|
|
];
|
|
|
|
/// 순위 아바타. 내 줄은 MY 프로필과 같은 시안 표본을 쓴다.
|
|
class RankAvatar extends StatelessWidget {
|
|
const RankAvatar({super.key, required this.rank, required this.size});
|
|
|
|
final ContributorRank rank;
|
|
final double size;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => rank.isMe
|
|
? ClipOval(
|
|
child: Image.asset(
|
|
'assets/figma/avatar-sample.png',
|
|
width: size,
|
|
height: size,
|
|
fit: BoxFit.cover,
|
|
),
|
|
)
|
|
: Container(
|
|
width: size,
|
|
height: size,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
// 시안 목록은 4위부터 검정 · 보라 · 분홍 · 올리브 · 연분홍 순이다.
|
|
color: _avatarColors[(rank.rank + 1) % _avatarColors.length],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 순위 숫자 아래 변동값 — 상승 #f28180 ▲ · 하락 #6866f3 ▼ · 변동 없음 「-」.
|
|
class RankChange extends StatelessWidget {
|
|
const RankChange({super.key, required this.change});
|
|
|
|
final int change;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (change == 0) {
|
|
return Text(
|
|
'-',
|
|
style: campaignText(10, 500, const Color(0xFF777777), height: 1),
|
|
);
|
|
}
|
|
final up = change > 0;
|
|
final color = up ? const Color(0xFFF28180) : AppColors.neonViolet;
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
up ? '▲' : '▼',
|
|
style: TextStyle(fontSize: 8, height: 1, color: color),
|
|
),
|
|
const SizedBox(width: 2),
|
|
Text('${change.abs()}', style: campaignText(10, 500, color, height: 1)),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 순위 한 줄 — 높이 60 · [순위+변동] 28 [아바타 40(원 36) · 6 · 닉네임 14/500 #979b9e] … 기여 POP 16/600.
|
|
class RankRow extends StatelessWidget {
|
|
const RankRow({
|
|
super.key,
|
|
required this.rank,
|
|
this.padding = const EdgeInsets.symmetric(vertical: 10),
|
|
});
|
|
|
|
final ContributorRank rank;
|
|
final EdgeInsets padding;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Padding(
|
|
padding: padding,
|
|
child: Row(
|
|
children: [
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(minWidth: 17),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'${rank.rank}',
|
|
style: campaignText(14, 600, AppColors.onCosmic, height: 1),
|
|
),
|
|
const SizedBox(height: 2),
|
|
RankChange(change: rank.change),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 28),
|
|
// 사본 — 40 프레임 가운데 지름 36 원.
|
|
SizedBox.square(
|
|
dimension: 40,
|
|
child: Center(child: RankAvatar(rank: rank, size: 36)),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(
|
|
rank.name,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: campaignText(14, 500, const Color(0xFF979B9E), height: 1),
|
|
),
|
|
),
|
|
Text(
|
|
formatReaction(rank.pop),
|
|
style: campaignText(16, 600, AppColors.onCosmic, height: 1.4),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 1~3위 단상 — 2 · 1 · 3 순서, 1위만 20 위로 올린다. 왕관 24 · 아바타 80.
|
|
class RankPodium extends StatelessWidget {
|
|
const RankPodium({super.key, required this.top});
|
|
|
|
/// 순위 오름차순 앞 세 줄.
|
|
final List<ContributorRank> top;
|
|
|
|
static const _crown = {
|
|
1: Color(0xFFFEA300),
|
|
2: Color(0xFFACACAC),
|
|
3: Color(0xFF9C724A),
|
|
};
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final byRank = {for (final r in top) r.rank: r};
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
for (final (i, place) in const [2, 1, 3].indexed) ...[
|
|
if (i > 0) const SizedBox(width: 24),
|
|
SizedBox(width: 81, child: _place(byRank[place], place)),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget? _place(ContributorRank? r, int place) => r == null
|
|
? null
|
|
: Padding(
|
|
padding: EdgeInsets.only(top: place == 1 ? 0 : 20),
|
|
child: Column(
|
|
children: [
|
|
_Crown(color: _crown[place]!, place: place),
|
|
RankAvatar(rank: r, size: 80),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
r.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: campaignText(14, 600, AppColors.onCosmic, height: 1),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
formatReaction(r.pop),
|
|
style: campaignText(16, 600, AppColors.onCosmic, height: 1.4),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 체스 킹 왕관(시안 material-symbols:chess-king) — 십자 · 몸통을 그리고 가운데에 순위.
|
|
class _Crown extends StatelessWidget {
|
|
const _Crown({required this.color, required this.place});
|
|
|
|
final Color color;
|
|
final int place;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
child: CustomPaint(
|
|
painter: _CrownPainter(color),
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: 10),
|
|
child: Center(
|
|
child: Text(
|
|
'$place',
|
|
style: campaignText(10, 900, AppColors.onCosmic, height: 1),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
class _CrownPainter extends CustomPainter {
|
|
const _CrownPainter(this.color);
|
|
|
|
final Color color;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final p = Paint()..color = color;
|
|
final w = size.width;
|
|
// 십자
|
|
canvas.drawRect(Rect.fromLTWH(w / 2 - 1, 1, 2, 7), p);
|
|
canvas.drawRect(Rect.fromLTWH(w / 2 - 3.5, 3, 7, 2), p);
|
|
// 몸통
|
|
canvas.drawRRect(
|
|
RRect.fromRectAndRadius(
|
|
Rect.fromLTWH(3, 8, w - 6, 15),
|
|
const Radius.circular(4),
|
|
),
|
|
p,
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(_CrownPainter old) => old.color != color;
|
|
}
|