INSIDE · STATION · 참여형 번역 · 알림 · MY 화면
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user