Files
fanuzu/app/lib/features/my/widgets/my_cards.dart
T

436 lines
13 KiB
Dart

import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/app_icons.dart';
import '../../../core/ui/relative_time.dart' show formatReaction;
import '../../home/home_screen.dart' show formatPop;
import '../my_data.dart';
import '../../../core/i18n/app_locale.dart';
/// MY 시안(`373:632962` 외 F6 전체)에서 되풀이되는 색. 토큰에 없는 Figma 값만 둔다.
abstract final class MyColors {
/// 카드 바탕 #19191c.
static const card = Color(0xFF19191C);
/// 카드 안 구분선 #2c2c2c.
static const divider = Color(0xFF2C2C2C);
/// 행 끝 chevron #444444.
static const chevron = Color(0xFF444444);
/// 보조 값(닉네임 · 이메일 오른쪽) #bbbbbb.
static const value = Color(0xFFBBBBBB);
/// 섹션 머리 #999999.
static const section = Color(0xFF999999);
/// 통계 라벨 #777777.
static const muted = Color(0xFF777777);
/// 위험 행(로그아웃) #ff5e5e.
static const danger = Color(0xFFFF5E5E);
}
/// 42dot Sans 스타일 한 줄. Figma 자간 -2.5% 를 크기에 맞춰 넣는다.
TextStyle myText(
double size,
FontWeight weight, {
Color color = AppColors.onCosmic,
double height = 1.4,
}) => TextStyle(
fontFamily: AppFonts.body,
fontSize: size,
fontWeight: weight,
height: height,
letterSpacing: size * -0.025,
color: color,
);
/// 행 끝 chevron-right 24.
class MyChevron extends StatelessWidget {
const MyChevron({super.key});
@override
Widget build(BuildContext context) =>
const ImageIcon(AppIcons.chevronRight, size: 24, color: MyColors.chevron);
}
/// 티어 카드 — 시안 MY `373:632962` 「GLOW TIER ⓘ · 진행 바 · 누적 기여 / 값 · ›」 틀.
///
/// C-18 — 제목 자리에 User Level(`Lv.N` + 칭호)을, 그 옆에 Passport Tier(B-65)를
/// 따로 적는다(FR-MY-01 세 체계 구분). 진행 바는 레벨 구간 EXP 진행도다.
/// [onInfo] · [onDetail] 이 없으면(프로필 카드 오버레이) 아이콘과 화살표를 그리지 않는다.
class MyTierCard extends StatelessWidget {
const MyTierCard({
super.key,
required this.profile,
this.onInfo,
this.onDetail,
});
final MyProfile profile;
final VoidCallback? onInfo;
final VoidCallback? onDetail;
@override
Widget build(BuildContext context) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onDetail,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: MyColors.card,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Row(
children: [
Image.asset(
'assets/figma/icon-tier-sprout.png',
width: 60,
height: 60,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
profile.levelLabel,
style: myText(14, FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
// Passport Tier 는 레벨과 별개 체계다(B-65 · B-66).
Text(
' · ${profile.passportTier.label}',
style: myText(14, FontWeight.w500, color: MyColors.value),
),
if (onInfo != null) ...[
const SizedBox(width: 4),
Semantics(
button: true,
label: context.l10n.tierGuide,
child: GestureDetector(
onTap: onInfo,
child: const ImageIcon(
AssetImage('assets/figma/icon-info.png'),
size: 14,
color: MyColors.value,
),
),
),
],
],
),
const SizedBox(height: 8),
MyProgressBar(value: profile.levelProgress),
const SizedBox(height: 8),
Row(
children: [
Text(
'EXP',
style: myText(
12,
FontWeight.w500,
color: AppColors.cosmicAccent,
),
),
const Spacer(),
Text(
formatPop(profile.userExp),
style: myText(
12,
FontWeight.w500,
color: AppColors.cosmicAccent,
),
),
],
),
],
),
),
if (onDetail != null) ...[
const SizedBox(width: 12),
const MyChevron(),
],
],
),
),
);
}
/// 진행 바 — 높이 10 · 트랙 검정 20% · 채움 분홍 방사 그라데이션(시안 Rectangle 32988).
class MyProgressBar extends StatelessWidget {
const MyProgressBar({super.key, required this.value});
final double value;
@override
Widget build(BuildContext context) => ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.full),
child: SizedBox(
height: 10,
child: Stack(
children: [
const Positioned.fill(child: ColoredBox(color: Color(0x33000000))),
FractionallySizedBox(
widthFactor: value.clamp(0.0, 1.0),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.full),
gradient: const RadialGradient(
radius: 4,
colors: [
Color(0xFFFF7DDD),
Color(0xFFFF97E4),
Color(0xFFFF7DDD),
],
stops: [0, 0.7, 1],
),
),
),
),
],
),
),
);
}
/// 통계 카드 — 시안 MY: 3D 아이콘 36 · 라벨 12/600 #777 · 값 16/600.
class MyStatsCard extends StatelessWidget {
const MyStatsCard({super.key, required this.profile});
final MyProfile profile;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
decoration: BoxDecoration(
color: MyColors.card,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
// 세 칸을 고르게 나눈다. 시안의 칸 안쪽 20 을 그대로 두면 여섯 자리 수치에서 넘친다.
child: Row(
children: [
// 시안 표기 「1.0K」 — 걸음은 K 단위로 줄인다. POP 은 천 단위 구분자다.
_Stat(
icon: 'assets/figma/icon-steps.png',
label: context.l10n.statSteps,
value: formatReaction(profile.steps),
),
_Stat(
icon: 'assets/figma/icon-pop-bolt.png',
label: context.l10n.statContribution,
value: formatPop(profile.contributedPop),
),
_Stat(
icon: 'assets/figma/icon-quests.png',
label: context.l10n.statQuests,
value: '${profile.completedQuests}',
),
],
),
);
}
class _Stat extends StatelessWidget {
const _Stat({required this.icon, required this.label, required this.value});
final String icon;
final String label;
final String value;
@override
Widget build(BuildContext context) => Expanded(
child: Column(
children: [
Image.asset(icon, width: 36, height: 36),
const SizedBox(height: 6),
Text(label, style: myText(12, FontWeight.w600, color: MyColors.muted)),
const SizedBox(height: 12),
Text(value, style: myText(16, FontWeight.w600)),
],
),
);
}
/// 두 칸 탭 — 시안 「행성/성운 · 활동」: 칸 높이 44 · 글자 16/500 · 활성 흰 밑줄,
/// 비활성 #1a1a1a 밑줄 · 비활성 글자 #bbbbbb.
class MyTabBar extends StatelessWidget {
const MyTabBar({super.key, required this.tabs});
final List<String> tabs;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TabBar(
labelColor: AppColors.onCosmic,
unselectedLabelColor: MyColors.value,
indicator: const UnderlineTabIndicator(
borderSide: BorderSide(color: AppColors.onCosmic, width: 2),
),
indicatorSize: TabBarIndicatorSize.tab,
dividerColor: const Color(0xFF1A1A1A),
dividerHeight: 2,
labelPadding: EdgeInsets.zero,
labelStyle: myText(16, FontWeight.w500, height: 1),
unselectedLabelStyle: myText(16, FontWeight.w500, height: 1),
tabs: [for (final t in tabs) Tab(height: 44, text: t)],
),
);
}
/// 정렬 칩 — 시안 Chip(`370:228289`): 높이 32 · 모서리 8 · 아이콘 16 · 글자 12/500.
/// 선택은 흰 바탕에 검정 글자, 비선택은 #1a1a1a 바탕 · #232324 획 · #d5d7d9 글자.
class MySortChip extends StatelessWidget {
const MySortChip({
super.key,
required this.label,
required this.icon,
required this.selected,
required this.onTap,
});
static const clock = AssetImage('assets/figma/icon-clock.png');
static const bolt = AssetImage('assets/figma/icon-pop-small.png');
static const fileCheck = AssetImage('assets/figma/icon-file-check.png');
final String label;
final ImageProvider icon;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final fg = selected ? Colors.black : const Color(0xFFD5D7D9);
return GestureDetector(
onTap: onTap,
child: Container(
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: selected ? AppColors.onCosmic : const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(AppRadius.sm),
border: Border.all(color: AppColors.cosmicSurfaceRaised),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ImageIcon(icon, size: 16, color: fg),
const SizedBox(width: 4),
Text(
label,
style: myText(12, FontWeight.w500, color: fg, height: 1.3),
),
],
),
),
);
}
}
/// 섹션 — 머리 12/600 #999 · 간격 16 · 카드 #19191c 모서리 16 · 안쪽 20/16 · 줄 간격 16.
/// 섹션 사이는 24. [title] 이 없으면 카드만 그린다(로그아웃 · 탈퇴).
class MyGroup extends StatelessWidget {
const MyGroup({this.title, required this.children});
final String? title;
final List<Widget> children;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (title case final t?) ...[
Text(
t,
style: myText(
12,
FontWeight.w600,
color: MyColors.section,
height: 1,
),
),
const SizedBox(height: 16),
],
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
decoration: BoxDecoration(
color: MyColors.card,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (i, c) in children.indexed) ...[
if (i > 0) const SizedBox(height: 16),
c,
],
],
),
),
],
),
);
}
class MyDivider extends StatelessWidget {
const MyDivider();
@override
Widget build(BuildContext context) =>
const SizedBox(height: 1, child: ColoredBox(color: MyColors.divider));
}
/// 눌러서 들어가는 줄 — 높이 24 · 글자 16/600 · 끝 chevron(언어는 아래 화살표).
class MyLinkRow extends StatelessWidget {
const MyLinkRow({
this.label,
this.child,
this.color = AppColors.onCosmic,
this.down = false,
required this.onTap,
});
final String? label;
final Widget? child;
final Color color;
final bool down;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox(
height: child == null ? 24 : 25,
child: Row(
children: [
Expanded(
child:
child ??
Text(
label!,
style: myText(16, FontWeight.w600, color: color, height: 1),
),
),
if (down)
const ImageIcon(
AssetImage('assets/figma/icon-chevron-down.png'),
size: 24,
color: Color(0xFF4F4F4F),
)
else
const MyChevron(),
],
),
),
);
}