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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/app_icons.dart';
|
||||
import '../my_data.dart';
|
||||
import 'my_cards.dart';
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 프로필 카드 오버레이 — 시안 `374:15738`(명세 `549:22058`).
|
||||
///
|
||||
/// 닫기 · 프로필 사진 80 · 닉네임 · 티어 카드 · 통계 카드 · 공유 4종(36 원형 아이콘).
|
||||
/// *인스타 공유 · 틱톡 공유 · X 공유 · 링크 복사*
|
||||
class ProfileCard extends StatelessWidget {
|
||||
const ProfileCard({super.key, required this.profile, required this.onShare});
|
||||
|
||||
final MyProfile profile;
|
||||
final void Function(String target) onShare;
|
||||
|
||||
/// 공유 대상과 시안 아이콘. 명세가 적은 순서 그대로다.
|
||||
static List<String> get shareTargets => [
|
||||
appL10n.shareInstagram,
|
||||
appL10n.shareTiktok,
|
||||
'X',
|
||||
appL10n.shareCopyLink,
|
||||
];
|
||||
static const _shareIcons = [
|
||||
'assets/figma/share-instagram.png',
|
||||
'assets/figma/share-tiktok.png',
|
||||
'assets/figma/share-x.png',
|
||||
'assets/figma/share-link.png',
|
||||
];
|
||||
|
||||
ImageProvider<Object> get _photo {
|
||||
final url = profile.photoUrl;
|
||||
return url == null || url.isEmpty
|
||||
? const AssetImage('assets/figma/avatar-sample.png')
|
||||
: NetworkImage(url);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: IconButton(
|
||||
tooltip: context.l10n.commonClose,
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints.tightFor(width: 24, height: 24),
|
||||
icon: const ImageIcon(
|
||||
AppIcons.x,
|
||||
size: 24,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
CircleAvatar(
|
||||
radius: 40,
|
||||
backgroundColor: AppColors.cosmicSurfaceRaised,
|
||||
backgroundImage: _photo,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(profile.nickname, style: myText(20, FontWeight.w600)),
|
||||
const SizedBox(height: 24),
|
||||
MyTierCard(profile: profile),
|
||||
const SizedBox(height: 19),
|
||||
MyStatsCard(profile: profile),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (final (i, target) in shareTargets.indexed) ...[
|
||||
if (i > 0) const SizedBox(width: 16),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: target,
|
||||
child: Tooltip(
|
||||
message: target,
|
||||
child: GestureDetector(
|
||||
onTap: () => onShare(target),
|
||||
child: ClipOval(
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
color: Colors.black,
|
||||
alignment: Alignment.center,
|
||||
child: Image.asset(
|
||||
_shareIcons[i],
|
||||
// X 원본은 21 짜리 글리프라 검정 원 안에 그대로 둔다.
|
||||
width: i == 2 ? 21 : 36,
|
||||
height: i == 2 ? 21 : 36,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user