INSIDE · STATION · 참여형 번역 · 알림 · MY 화면
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../auth/auth.dart';
|
||||
|
||||
/// SNS 연동 — 화면 대조 원장 F6.
|
||||
///
|
||||
/// Figma `357:222740`(Apple) · `357:222899`(Google) · `357:222671`(Kakao).
|
||||
/// **화면들이 제공자만 다르고 같다** — 따로 만들면 연동 규칙이 여러 벌이 되고,
|
||||
/// 규칙이 바뀔 때 한 곳만 고치는 사고가 난다.
|
||||
///
|
||||
/// 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.*
|
||||
/// X(트위터)는 확정 4종에 없어 목록에서 빠졌다([linkableProviders]).
|
||||
///
|
||||
/// 명세 `554:22797` — *플랫폼별 연동 상태 반영 · **동일 이메일만 연동 가능***
|
||||
/// 판정은 [sameAccountIdentity] 가 하고, 실제 연결은 [AccountLinker] 구현체
|
||||
/// (Firebase `linkWithCredential`)가 한다.
|
||||
class AccountLinkScreen extends StatefulWidget {
|
||||
const AccountLinkScreen({
|
||||
super.key,
|
||||
required this.provider,
|
||||
required this.linker,
|
||||
this.linkedEmail,
|
||||
});
|
||||
|
||||
final AuthProviderKind provider;
|
||||
|
||||
/// 연결 경계. Firebase 어댑터이거나 [SampleAccountLinker] 다.
|
||||
final AccountLinker linker;
|
||||
|
||||
/// 이미 연동된 이메일. 없으면 미연동.
|
||||
final String? linkedEmail;
|
||||
|
||||
@override
|
||||
State<AccountLinkScreen> createState() => _AccountLinkScreenState();
|
||||
}
|
||||
|
||||
class _AccountLinkScreenState extends State<AccountLinkScreen> {
|
||||
late String? _linked = widget.linkedEmail;
|
||||
bool _busy = false;
|
||||
|
||||
bool get _connected => _linked != null;
|
||||
|
||||
String get _name => widget.provider.shortName;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(context.l10n.linkTitle(_name))),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(_name, style: t.titleMedium),
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
Text(_linked ?? context.l10n.linkNotLinked, style: t.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
Text(context.l10n.linkSameEmailOnly, style: t.labelSmall),
|
||||
const SizedBox(height: AppSpace.s5),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : _toggle,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(AppSizing.touchMinApp),
|
||||
backgroundColor: _connected
|
||||
? AppColors.cosmicSurfaceRaised
|
||||
: AppColors.cosmicAccent,
|
||||
foregroundColor: _connected
|
||||
? AppColors.onCosmic
|
||||
: AppColors.onCosmicAccent,
|
||||
),
|
||||
child: Text(
|
||||
_connected ? context.l10n.linkUnlink : context.l10n.linkLink,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
// 명세 `554:23411` — 연동 해제가 계정 삭제로 오인되지 않게 한다.
|
||||
Text(context.l10n.settingsUnlinkBody, style: t.labelSmall),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggle() async {
|
||||
final wasConnected = _connected;
|
||||
setState(() => _busy = true);
|
||||
final outcome = wasConnected
|
||||
? await widget.linker.unlink(widget.provider)
|
||||
: await widget.linker.link(widget.provider);
|
||||
if (!mounted) return;
|
||||
|
||||
// 화면 상태를 추측하지 않고 연결 경계에 다시 물어본다 — 성공했다고 믿고
|
||||
// 로컬 값을 뒤집으면 서버가 거절한 연결이 붙은 것처럼 보인다.
|
||||
final accounts = widget.linker.linkedAccounts;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_linked = accounts.containsKey(widget.provider)
|
||||
// 붙었는데 제공자가 이메일을 안 준 경우(Apple 재연동)도 연결 상태다.
|
||||
? accounts[widget.provider] ?? context.l10n.linkLinked
|
||||
: null;
|
||||
});
|
||||
|
||||
switch (outcome) {
|
||||
case AuthFailed(:final code, :final message):
|
||||
_toast('[$code] $message');
|
||||
case AuthNotConfigured(:final missing):
|
||||
_toast(context.l10n.settingsLinkNotReady(_name, missing.join(' · ')));
|
||||
case AuthSucceeded() || AuthCancelled():
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _toast(String message) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../campaign/campaign_data.dart';
|
||||
import '../campaign/campaign_detail_screen.dart';
|
||||
import '../campaign/widgets/campaign_list_card.dart';
|
||||
import '../inside/inside_data.dart';
|
||||
import '../inside/inside_detail_screen.dart';
|
||||
import '../inside/inside_screen.dart' show InsideQuestionCard;
|
||||
import 'widgets/my_cards.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 내 인사이드 · 내 캠페인 — 화면 대조 원장 F6.
|
||||
///
|
||||
/// Figma `370:228289`(내 인사이드) · `370:229665`(내 캠페인).
|
||||
/// 명세 `554:22382`(게시글 리스트 영역) 계열이며 **머리글과 빈 상태만 다르고**
|
||||
/// 구조가 같아 한 화면에 종류를 받아 쓴다.
|
||||
enum MyListKind {
|
||||
inside,
|
||||
campaign;
|
||||
|
||||
String get title => switch (this) {
|
||||
inside => appL10n.myListInside,
|
||||
campaign => appL10n.myListCampaign,
|
||||
};
|
||||
|
||||
String get emptyMessage => switch (this) {
|
||||
inside => appL10n.myListInsideEmpty,
|
||||
campaign => appL10n.myListCampaignEmpty,
|
||||
};
|
||||
}
|
||||
|
||||
/// 정렬 칩 — 시안 `370:228289` 「최신순 · POP순 · 채택 완료」 · `370:229665` 「최신순 · 달성 완료」.
|
||||
enum _MySort { latest, pop, solved, achieved }
|
||||
|
||||
class MyListsScreen extends StatefulWidget {
|
||||
const MyListsScreen({super.key, required this.kind, this.campaigns});
|
||||
|
||||
final MyListKind kind;
|
||||
|
||||
/// 내 캠페인의 원천. null 이면 표본(목 모드). 저장소 선택은 repositories.dart.
|
||||
final CampaignRepository? campaigns;
|
||||
|
||||
@override
|
||||
State<MyListsScreen> createState() => _MyListsScreenState();
|
||||
}
|
||||
|
||||
class _MyListsScreenState extends State<MyListsScreen> {
|
||||
_MySort _sort = _MySort.latest;
|
||||
|
||||
/// 표본 — 시안 `370:228289` 문구. 서버가 붙으면 INSIDE 저장소의 내 글로 옮긴다.
|
||||
static final _insides = () {
|
||||
final now = DateTime.now();
|
||||
return <InsideQuestion>[
|
||||
InsideQuestion(
|
||||
id: 'i1',
|
||||
title: 'BTS 컴백 알려주세요!',
|
||||
body: 'BTS 한국 컴백 시기가 궁급합니다!\n아시는 분들 댓글 부탁드려요 ㅠㅠ',
|
||||
authorName: 'BTS_Hong',
|
||||
createdAt: now,
|
||||
bountyPop: 100,
|
||||
isMine: true,
|
||||
),
|
||||
InsideQuestion(
|
||||
id: 'i2',
|
||||
title: '화양연화 세계관 해석해주실 분?',
|
||||
body: '이번 뮤비랑 연결되는 부분이 헷갈리네요. 타임라인 정리해주실 천재 아미 구합니다!',
|
||||
authorName: 'BTS_Hong',
|
||||
createdAt: now.subtract(const Duration(minutes: 10)),
|
||||
bountyPop: 500,
|
||||
viewCount: 3,
|
||||
isMine: true,
|
||||
),
|
||||
InsideQuestion(
|
||||
id: 'i3',
|
||||
title: 'BTS 이 포즈 뭔가요?',
|
||||
body: '아미 분들 이 포즈 뭔지 알려주실 수 있나요?',
|
||||
authorName: 'BTS_Hong',
|
||||
createdAt: now.subtract(const Duration(days: 30)),
|
||||
bountyPop: 800,
|
||||
answerCount: 24,
|
||||
viewCount: 82,
|
||||
isMine: true,
|
||||
isSolved: true,
|
||||
),
|
||||
];
|
||||
}();
|
||||
|
||||
List<_MySort> get _sorts => switch (widget.kind) {
|
||||
MyListKind.inside => const [_MySort.latest, _MySort.pop, _MySort.solved],
|
||||
MyListKind.campaign => const [_MySort.latest, _MySort.achieved],
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.kind.title),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(36),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 2, 16, 2),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final (i, sort) in _sorts.indexed) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
MySortChip(
|
||||
label: switch (sort) {
|
||||
_MySort.latest => context.l10n.sortLatest,
|
||||
_MySort.pop => context.l10n.sortPop,
|
||||
_MySort.solved => context.l10n.sortSolved,
|
||||
_MySort.achieved => context.l10n.sortAchieved,
|
||||
},
|
||||
icon: switch (sort) {
|
||||
_MySort.latest => MySortChip.clock,
|
||||
_MySort.pop => MySortChip.bolt,
|
||||
_ => MySortChip.fileCheck,
|
||||
},
|
||||
selected: _sort == sort,
|
||||
onTap: () => setState(() => _sort = sort),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: switch (widget.kind) {
|
||||
MyListKind.inside => _insideList(context),
|
||||
MyListKind.campaign => _campaignList(context),
|
||||
},
|
||||
);
|
||||
|
||||
Widget _insideList(BuildContext context) {
|
||||
final items = switch (_sort) {
|
||||
_MySort.pop => [
|
||||
..._insides,
|
||||
]..sort((a, b) => b.bountyPop.compareTo(a.bountyPop)),
|
||||
_MySort.solved => _insides.where((q) => q.isSolved).toList(),
|
||||
_ => _insides,
|
||||
};
|
||||
if (items.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
widget.kind.emptyMessage,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
);
|
||||
}
|
||||
// 카드는 INSIDE 목록과 같은 것을 쓴다(시안도 같은 컴포넌트다).
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, _) => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 2),
|
||||
child: SizedBox(height: 1, child: ColoredBox(color: Color(0xFF1A1A1A))),
|
||||
),
|
||||
itemBuilder: (context, i) => InsideQuestionCard(
|
||||
question: items[i],
|
||||
onTap: () => Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => InsideDetailScreen(question: items[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _campaignList(BuildContext context) =>
|
||||
FutureBuilder<List<CampaignCardData>>(
|
||||
future: (widget.campaigns ?? SampleCampaignRepository()).mine(),
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
// 내가 만든 것만 남긴다. 「달성 완료」는 목표를 채운 것만.
|
||||
final mine = (snap.data ?? const [])
|
||||
.where((c) => c.mine)
|
||||
.where(
|
||||
(c) =>
|
||||
_sort != _MySort.achieved ||
|
||||
c.status == CampaignStatus.success,
|
||||
)
|
||||
.toList();
|
||||
if (mine.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
widget.kind.emptyMessage,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: mine.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 16),
|
||||
itemBuilder: (context, i) => CampaignListCard(
|
||||
card: mine[i],
|
||||
mine: true,
|
||||
onOpen: () => Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => CampaignDetailScreen(
|
||||
campaignId: mine[i].id,
|
||||
repository: widget.campaigns,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_storage/firebase_storage.dart';
|
||||
|
||||
import '../../core/data/backend.dart';
|
||||
import '../home/home_repository_firestore.dart' show kstDayKey;
|
||||
import 'my_data.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// MY 의 실 서버 구현 — 화면 대조 원장 F6.
|
||||
///
|
||||
/// 서버가 실제로 들고 있는 것만 덮어쓴다. 결제 내역·환불 요청도 이제 서버 문서
|
||||
/// (`users/{uid}/purchases`)를 읽고 쓴다.
|
||||
/// 그래서 상속으로 붙이고 필요한 것만 덮는다 — 인터페이스를 빈 껍데기로 다시
|
||||
/// 쓰면 어느 것이 진짜인지 화면에서 알 수 없다.
|
||||
///
|
||||
/// 읽는 곳 — **서버가 실제로 쓰는 필드**를 읽는다.
|
||||
/// - 프로필 `users/{uid}` (nickname · photoUrl · originNo · steps · completedQuests)
|
||||
/// - 누적 기여 POP `users/{uid}.totalContributedPop`(`fn-pop-spend` 가 갱신, 설계서 §3.6)
|
||||
/// - 레벨 `users/{uid}.{userExp, userLevel}` · Passport `users/{uid}.passportTier`
|
||||
/// (C-18 · B-65). 필드가 없는 기존 사용자는 Lv.1 · SILVER 로 둔다.
|
||||
/// - 잔액 `users/{uid}/wallet/main.activityBalance`
|
||||
/// - 행성별 기여 `fandoms/{fandomId}` + `fandoms/{fandomId}/members/{uid}.contributedPop`
|
||||
/// - POP 원장 `users/{uid}/popTransactions` (최신순 · 커서 `createdAt`)
|
||||
/// - 공지 `notices`
|
||||
/// - 차단 `users/{uid}/blocks/{targetUid}`
|
||||
/// - 문의 `inquiries` (본인 것만)
|
||||
/// - 결제 `users/{uid}/purchases` (`fn-iap-verify` · 환불 요청 한 전이만 본인 쓰기)
|
||||
/// - 푸시 설정 `users/{uid}.notificationPrefs` (보안 규칙이 본인 쓰기를 허용한 필드)
|
||||
///
|
||||
/// **예전에는 `users.contributedPop` 과 `planets/{uid}.contributedPop` 을 읽었다.**
|
||||
/// 서버가 쓰는 이름이 아니어서 기여가 쌓여도 화면이 0 에 머물렀다. 등급도 앱이
|
||||
/// 문턱표로 다시 계산해 서버 값과 갈릴 수 있었다.
|
||||
///
|
||||
/// 쓰는 곳은 재화가 오가지 않는 다섯 갈래뿐이다 — 닉네임·사진 URL·푸시 설정(`users/{uid}`),
|
||||
/// 차단(`users/{uid}/blocks`), 문의 접수(`inquiries`), 환불 요청(`users/{uid}/purchases`). POP 과 기여는 여기서
|
||||
/// 건드리지 않는다(별첨 1 ② 「클라이언트 단독 지급 0건」).
|
||||
class FirestoreMyRepository extends SampleMyRepository {
|
||||
FirestoreMyRepository(this._backend);
|
||||
|
||||
final Backend _backend;
|
||||
|
||||
@override
|
||||
Future<MyProfile> profile() async {
|
||||
final uid = _backend.requireUid;
|
||||
final db = _backend.db;
|
||||
|
||||
final userF = db.doc('users/$uid').get();
|
||||
final walletF = db.doc('users/$uid/wallet/main').get();
|
||||
final user = (await userF).data() ?? const <String, dynamic>{};
|
||||
final wallet = (await walletF).data() ?? const <String, dynamic>{};
|
||||
|
||||
final fandomId = user.strOr('fandomId');
|
||||
var tiers = const <PlanetTier>[];
|
||||
if (fandomId.isNotEmpty) {
|
||||
final fandomF = db.doc('fandoms/$fandomId').get();
|
||||
final memberF = db.doc('fandoms/$fandomId/members/$uid').get();
|
||||
final fandom = (await fandomF).data() ?? const <String, dynamic>{};
|
||||
final member = (await memberF).data() ?? const <String, dynamic>{};
|
||||
tiers = [
|
||||
PlanetTier(
|
||||
fandomName: fandom.strOr('name', fandomId),
|
||||
// 팬덤별 누적 기여 POP. `fn-pop-spend` 가 기여 차감 때 함께 올린다.
|
||||
contributedPop: member.intOr('contributedPop'),
|
||||
isPrimary: true,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// 닉네임이 아직 없으면(온보딩 전) 중립 이름으로 부른다. 「— 님」은 고장처럼 보인다.
|
||||
// **이메일 앞부분을 쓰지 않는다** — 프로필 수정이 그 값을 그대로
|
||||
// 닉네임으로 저장해 홈 기여자명으로 퍼졌다. 이메일은 설정 「계정 정보」
|
||||
// (명세 `554:22634` *닉네임 · 가입 이메일 값*)에만 둔다.
|
||||
final email = _backend.email ?? '';
|
||||
|
||||
final photoUrl = user.strOr('photoUrl');
|
||||
|
||||
return MyProfile(
|
||||
nickname: publicName(user['nickname']),
|
||||
email: email,
|
||||
contributedPop: user.intOr('totalContributedPop'),
|
||||
userExp: user.intOr('userExp'),
|
||||
userLevel: user.intOr('userLevel').clamp(1, 1 << 31),
|
||||
passportTier: PassportTier.parse(user.strOr('passportTier')),
|
||||
balancePop: wallet.intOr('activityBalance'),
|
||||
steps: user.intOr('steps'),
|
||||
completedQuests: user.intOr('completedQuests'),
|
||||
planetTiers: tiers,
|
||||
photoUrl: photoUrl.isEmpty ? null : photoUrl,
|
||||
originNo: user.containsKey('originNo') ? user.intOr('originNo') : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 푸시 알림 설정 — 정책 원장 B-77 5 개 구분.
|
||||
///
|
||||
/// `notificationPrefs` 를 쓴 이유는 보안 규칙이다. `firestore.rules` 의
|
||||
/// `users/{uid}` 업데이트 화이트리스트에 이 이름이 이미 들어 있어, 다른 이름을
|
||||
/// 쓰면 클라이언트 쓰기가 규칙 층에서 거부된다.
|
||||
///
|
||||
/// 문서에 없는 구분은 정책 기본값으로 채운다 — 구분이 추가될 때 기존 계정의
|
||||
/// 문서를 일괄 수정하지 않아도 되고, Marketing 은 그 기본값이 OFF 다.
|
||||
@override
|
||||
Future<Map<PushTopic, bool>> pushSettings() async {
|
||||
final uid = _backend.requireUid;
|
||||
final snap = await _backend.db.doc('users/$uid').get();
|
||||
final prefs = snap.data()?['notificationPrefs'];
|
||||
final saved = prefs is Map ? prefs : const <dynamic, dynamic>{};
|
||||
return {
|
||||
for (final topic in PushTopic.values)
|
||||
topic: saved[topic.name] is bool
|
||||
? saved[topic.name] as bool
|
||||
: topic.defaultOn,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setPush(PushTopic topic, bool on) async {
|
||||
// 서비스 필수는 끌 수 없으므로 저장하지 않는다. 값이 남으면 다음에 읽을 때
|
||||
// false 가 되어 화면과 정책이 어긋난다.
|
||||
if (topic.locked) return;
|
||||
final uid = _backend.requireUid;
|
||||
await _backend.db.doc('users/$uid').set({
|
||||
'notificationPrefs': {topic.name: on},
|
||||
'updatedAt': FieldValue.serverTimestamp(),
|
||||
}, SetOptions(merge: true));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateNickname(String nickname) async {
|
||||
final uid = _backend.requireUid;
|
||||
await _backend.db.doc('users/$uid').set(
|
||||
{'nickname': nickname},
|
||||
SetOptions(merge: true),
|
||||
);
|
||||
}
|
||||
|
||||
/// POP 원장 — S-M-WALLET · 명세 `554:23732`.
|
||||
///
|
||||
/// 원장 한 행을 그대로 옮긴다. 합산·재계산을 하지 않는 것이 별첨 1 ②
|
||||
/// 「POP 원장 – MY 통계 3자 교차 대조」를 앱 쪽에서 지키는 방법이다.
|
||||
/// **금액에 부호가 실려 있다** — `spendPop` 이 `amount: -input.amount` 로 쓰고
|
||||
/// 설계서 §3.4 대조식이 `balance == Σ amount` 이므로, 화면이 부호를 다시
|
||||
/// 뒤집으면 차감이 지급으로 보인다.
|
||||
///
|
||||
/// 이어읽기는 시각 비교가 아니라 **커서 문서**로 한다. 한 트랜잭션이 일반·기여
|
||||
/// 두 종류를 각각 쓰면 두 행의 서버 타임스탬프가 같아지는데, `createdAt` 만
|
||||
/// 비교해 끊으면 그 경계에서 한 행이 사라진다. Firestore 는 정렬에 문서 이름
|
||||
/// (`__name__`)을 암묵으로 덧붙이므로 `startAfterDocument` 는 동률까지 가른다.
|
||||
/// 커서 문서를 한 번 더 읽는 값을 내지만, 원장 행이 조용히 빠지는 것보다 싸다.
|
||||
///
|
||||
/// 딱 하나 예외가 Zap 이다. 명세가 *Zap 횟수에 따라 리스트 생성이 아닌 당일 기준
|
||||
/// 총기여 값 및 시간 update* 를 요구하므로 같은 날 붙어 있는 `ECLIPSE_DEFENSE`
|
||||
/// 행을 한 줄로 접는다([_foldZap]).
|
||||
@override
|
||||
Future<List<PopLedgerEntry>> popHistory({
|
||||
int limit = 20,
|
||||
PopCursor? after,
|
||||
}) async {
|
||||
final uid = _backend.requireUid;
|
||||
final collection = _backend.db.collection('users/$uid/popTransactions');
|
||||
var query = collection.orderBy('createdAt', descending: true);
|
||||
if (after != null) {
|
||||
final anchor = await collection.doc(after.id).get();
|
||||
// 원장은 append-only 이고 규칙이 삭제를 막아 두었으므로 없을 수가 없다.
|
||||
// 그래도 없다면 그 자리를 찾을 방법이 없으니 끝으로 본다. 처음부터 다시
|
||||
// 읽으면 이미 그린 줄이 목록에 두 번 붙는다.
|
||||
if (!anchor.exists) return const [];
|
||||
query = query.startAfterDocument(anchor);
|
||||
}
|
||||
final snapshot = await query.limit(limit).get();
|
||||
|
||||
final rows = <_LedgerRow>[];
|
||||
for (final doc in snapshot.docs) {
|
||||
final data = doc.data();
|
||||
final reasonCode = data.strOr('reasonCode');
|
||||
final label = popReasonLabel(reasonCode);
|
||||
final at = data.dateOr('createdAt', _epoch);
|
||||
rows.add(_LedgerRow(
|
||||
reasonCode: reasonCode,
|
||||
// Zap 한 행이 몇 번분인가. `fn-eclipse-defend` 가 `count` 를 묶어 받으므로
|
||||
// 행 하나가 여러 번일 수 있다. 없는 문서는 한 번으로 본다.
|
||||
count: data.intOr('count', 1).clamp(1, 1 << 30),
|
||||
entry: PopLedgerEntry(
|
||||
at: at,
|
||||
title: label.title,
|
||||
type: label.type,
|
||||
kind: PopKind.parse(data.strOr('popType')),
|
||||
txType: PopTxType.parse(data.strOr('type', 'grant')),
|
||||
delta: data.intOr('amount'),
|
||||
balanceAfter: data.intOr('balanceAfter'),
|
||||
cursor: PopCursor(at: at, id: doc.id),
|
||||
),
|
||||
));
|
||||
}
|
||||
return _foldZap(rows);
|
||||
}
|
||||
|
||||
/// 서비스 결제 내역 — `users/{uid}/purchases`(`fn-iap-verify` 가 쓴다) 최신순.
|
||||
///
|
||||
/// 명세 `554:23841` *결제 일시 · 결제 아이템명 · 결제 아이템 가격 · 최신순 소팅*.
|
||||
/// 가격은 결제 때 스토어가 앱에 준 표시 가격이다(지급 판단에는 쓰지 않는 값).
|
||||
@override
|
||||
Future<List<PaymentEntry>> payments() async {
|
||||
final uid = _backend.requireUid;
|
||||
final snapshot = await _backend.db
|
||||
.collection('users/$uid/purchases')
|
||||
.orderBy('purchasedAt', descending: true)
|
||||
.limit(100)
|
||||
.get();
|
||||
return [for (final doc in snapshot.docs) _payment(doc.id, doc.data())];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PaymentEntry> payment(String id) async {
|
||||
final uid = _backend.requireUid;
|
||||
final doc = await _backend.db.doc('users/$uid/purchases/$id').get();
|
||||
final data = doc.data();
|
||||
if (data == null) {
|
||||
throw BackendException('E-PAY-02', appL10n.payNotFound);
|
||||
}
|
||||
return _payment(doc.id, data);
|
||||
}
|
||||
|
||||
/// 환불 요청 — 명세 `554:24008` *결제 완료 → 환불 요청으로 update*.
|
||||
///
|
||||
/// 보안 규칙이 **지급 완료 → 환불 요청** 한 전이와 이 세 필드만 허용한다. 승인 · 반려 ·
|
||||
/// 회수는 Admin 과 `fn-iap-refund-webhook` 이 한다. POP 은 여기서 건드리지 않는다.
|
||||
@override
|
||||
Future<void> requestRefund(String paymentId, String reason) async {
|
||||
final uid = _backend.requireUid;
|
||||
await _backend.db.doc('users/$uid/purchases/$paymentId').update({
|
||||
'state': 'refund-requested',
|
||||
'refundReason': reason,
|
||||
'refundRequestedAt': FieldValue.serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Focus 구독 — `users/{uid}/subscription/current`(`fn-iap-verify` · 구독 웹훅이 쓴다).
|
||||
/// 만료가 지났거나 해지·환불로 혜택이 꺼졌으면 null.
|
||||
@override
|
||||
Future<FocusStatus?> focus() async {
|
||||
final uid = _backend.requireUid;
|
||||
final data =
|
||||
(await _backend.db.doc('users/$uid/subscription/current').get()).data();
|
||||
if (data == null) return null;
|
||||
final expiresAt = data.dateOr('expiresAt', _epoch);
|
||||
final state = data.strOr('state');
|
||||
if (!expiresAt.isAfter(DateTime.now()) ||
|
||||
(state != 'active' && state != 'canceled')) {
|
||||
return null;
|
||||
}
|
||||
return FocusStatus(
|
||||
expiresAt: expiresAt,
|
||||
// 서버와 같은 일 경계(KST). `fn-focus-daily-claim` 이 받은 날을 여기 적는다.
|
||||
claimedToday: data.strOr('focusClaimedDay') == kstDayKey(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> claimFocusDaily() async =>
|
||||
(await _backend.call('fnFocusDailyClaim')).intOr('granted');
|
||||
|
||||
PaymentEntry _payment(String id, Map<String, dynamic> data) => PaymentEntry(
|
||||
id: id,
|
||||
at: data.dateOr('purchasedAt', _epoch),
|
||||
itemName: data.strOr('itemName', data.strOr('productId', appL10n.payItemFallback)),
|
||||
price: data.intOr('priceAmount'),
|
||||
method: data.strOr('method', appL10n.payMethodInApp),
|
||||
state: paymentStateOf(data.strOr('state')),
|
||||
);
|
||||
|
||||
/// 공지 — S-M-NOTICE · Figma `357:224659` 「공지사항」.
|
||||
///
|
||||
/// 필드와 정렬은 `firestore.indexes.json` 의 `notices` 복합 색인이 정해 둔
|
||||
/// `active` + `publishedAt DESC` 를 그대로 따른다. 다른 조합으로 읽으면 색인이
|
||||
/// 없어 실 배포에서 `failed-precondition` 으로 떨어진다 — 에뮬레이터는 색인을
|
||||
/// 강제하지 않으므로(`firebase/test/README.md` 「복합 인덱스 검증 한계 실측」)
|
||||
/// 여기서 어긋나도 테스트로는 잡히지 않는다.
|
||||
@override
|
||||
Future<List<NoticeItem>> notices() async {
|
||||
final snapshot = await _backend.db
|
||||
.collection('notices')
|
||||
.where('active', isEqualTo: true)
|
||||
.orderBy('publishedAt', descending: true)
|
||||
.limit(50)
|
||||
.get();
|
||||
return [
|
||||
for (final doc in snapshot.docs)
|
||||
NoticeItem(
|
||||
id: doc.id,
|
||||
title: doc.data().strOr('title', appL10n.noticeBadge),
|
||||
body: doc.data().strOr('body'),
|
||||
at: doc.data().dateOr('publishedAt', _epoch),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<BlockedUser>> blockedUsers() async {
|
||||
final uid = _backend.requireUid;
|
||||
final snapshot = await _backend.db.collection('users/$uid/blocks').get();
|
||||
final list = [
|
||||
for (final doc in snapshot.docs)
|
||||
BlockedUser(
|
||||
uid: doc.id,
|
||||
// UID 를 이름 자리에 내보내지 않는다(G-07).
|
||||
nickname: publicName(doc.data()['nickname'], appL10n.unknownUser),
|
||||
at: doc.data().dateOr('createdAt', _epoch),
|
||||
),
|
||||
];
|
||||
// 최근에 차단한 계정이 위로 온다. 규칙이 차단 문서 목록에 정렬 제약을 두지
|
||||
// 않으므로 색인 없이 여기서 세운다(목록이 사용자당 수십 건 규모다).
|
||||
list.sort((a, b) => (b.at ?? _epoch).compareTo(a.at ?? _epoch));
|
||||
return list;
|
||||
}
|
||||
|
||||
/// 차단 추가. 재화가 아니라 개인 설정이므로 규칙이 본인 직접 쓰기를 허용한다
|
||||
/// (`firestore.rules` — `users/{uid}/blocks/{targetUid}` 는 `isSelf(uid)`).
|
||||
@override
|
||||
Future<void> block(String targetUid, {String? nickname}) async {
|
||||
final uid = _backend.requireUid;
|
||||
if (targetUid == uid) {
|
||||
throw BackendException('E-PERM-01', appL10n.blockSelf);
|
||||
}
|
||||
await _backend.db.doc('users/$uid/blocks/$targetUid').set({
|
||||
if (nickname != null && nickname.isNotEmpty) 'nickname': nickname,
|
||||
'createdAt': FieldValue.serverTimestamp(),
|
||||
}, SetOptions(merge: true));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> unblock(String targetUid) async {
|
||||
final uid = _backend.requireUid;
|
||||
await _backend.db.doc('users/$uid/blocks/$targetUid').delete();
|
||||
}
|
||||
|
||||
/// 1:1 문의 접수 — Figma `373:635052` 「새 문의 작성」.
|
||||
///
|
||||
/// `status` 를 `pending` 으로 고정해 보내는 것은 규칙이 그렇게만 생성을 허용하기
|
||||
/// 때문이다(`allow create: if authorIsSelf() && status == 'pending'`).
|
||||
/// 상태를 올리는 것은 운영이다.
|
||||
@override
|
||||
Future<void> submitInquiry(String title, String body) async {
|
||||
final uid = _backend.requireUid;
|
||||
await _backend.db.collection('inquiries').add({
|
||||
'authorUid': uid,
|
||||
'title': title,
|
||||
'body': body,
|
||||
'status': 'pending',
|
||||
'createdAt': FieldValue.serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
/// 문의 수정. 규칙이 접수 상태의 제목·본문만 허용하므로 그 두 개와 갱신 시각만
|
||||
/// 보낸다. `status` 를 함께 보내면 규칙이 요청 전체를 거부한다.
|
||||
@override
|
||||
Future<void> updateInquiry(String id, String title, String body) async {
|
||||
await _backend.db.doc('inquiries/$id').update({
|
||||
'title': title,
|
||||
'body': body,
|
||||
'updatedAt': FieldValue.serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<InquiryItem>> myInquiries() async {
|
||||
final uid = _backend.requireUid;
|
||||
// `authorUid` 조건이 규칙의 목록 허용 조건과 같아야 질의 자체가 통과한다.
|
||||
final snapshot = await _backend.db
|
||||
.collection('inquiries')
|
||||
.where('authorUid', isEqualTo: uid)
|
||||
.orderBy('createdAt', descending: true)
|
||||
.limit(50)
|
||||
.get();
|
||||
return [
|
||||
for (final doc in snapshot.docs)
|
||||
InquiryItem(
|
||||
id: doc.id,
|
||||
title: doc.data().strOr('title', appL10n.inquiryFallback),
|
||||
body: doc.data().strOr('body'),
|
||||
at: doc.data().dateOr('createdAt', _epoch),
|
||||
status: InquiryStatus.parse(doc.data().strOr('status')),
|
||||
answerBody: doc.data().strOr('answerBody').isEmpty
|
||||
? null
|
||||
: doc.data().strOr('answerBody'),
|
||||
answeredAt: doc.data().containsKey('answeredAt')
|
||||
? doc.data().dateOr('answeredAt', _epoch)
|
||||
: null,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// 프로필 사진 업로드 — Storage 에 올리고 URL 을 사용자 문서에 남긴다.
|
||||
///
|
||||
/// 경로를 사용자당 한 장으로 고정한 이유는 고아 파일이다. 시각을 붙이면 바꿀
|
||||
/// 때마다 이전 파일이 남고 지우는 경로가 따로 필요해진다. 덮어쓰면 다운로드
|
||||
/// URL 의 토큰이 바뀌므로 캐시도 함께 끊긴다.
|
||||
///
|
||||
/// `photoUrl` 은 규칙의 본인 수정 허용 목록에 이미 들어 있는 필드다.
|
||||
@override
|
||||
Future<String> uploadProfilePhoto(PickedPhoto photo) async {
|
||||
checkProfilePhoto(photo);
|
||||
final uid = _backend.requireUid;
|
||||
final ref = _backend.storage.ref('users/$uid/profile/photo');
|
||||
try {
|
||||
await ref.putData(
|
||||
photo.bytes,
|
||||
SettableMetadata(contentType: photo.contentType),
|
||||
);
|
||||
} on FirebaseException catch (error) {
|
||||
// Storage 규칙이 용량·형식을 서버에서 한 번 더 본다(B-44). 거부를 그대로
|
||||
// 삼키면 화면이 성공으로 넘어가므로 사유가 있는 실패로 바꿔 올린다.
|
||||
throw PhotoRejected(
|
||||
error.code == 'unauthorized'
|
||||
? appL10n.photoRejectedServer
|
||||
: appL10n.photoUploadFailed,
|
||||
);
|
||||
}
|
||||
final url = await ref.getDownloadURL();
|
||||
await _backend.db.doc('users/$uid').set(
|
||||
{'photoUrl': url},
|
||||
SetOptions(merge: true),
|
||||
);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/// 원장 행 하나와 그 사유 코드·횟수. 접기 판정에만 쓰고 화면으로 나가지 않는다.
|
||||
class _LedgerRow {
|
||||
const _LedgerRow({
|
||||
required this.reasonCode,
|
||||
required this.entry,
|
||||
this.count = 1,
|
||||
});
|
||||
|
||||
final String reasonCode;
|
||||
final PopLedgerEntry entry;
|
||||
|
||||
/// 이 행이 몇 번분인가. 원장의 `count` 값이고, 없으면 1 이다.
|
||||
final int count;
|
||||
}
|
||||
|
||||
/// 최신순으로 붙어 있는 같은 날 Zap 행을 한 줄로 접는다.
|
||||
///
|
||||
/// 사이에 다른 거래가 끼면 그룹이 끊긴다. 그래야 접힌 줄의 「누적 POP」이 그 시점의
|
||||
/// 실제 잔액과 같다. 되돌아가 다시 묶으면 누적 값이 거짓이 된다.
|
||||
///
|
||||
/// 접는 판정과 합치는 규칙은 [PopLedgerEntry.foldsWith] · [PopLedgerEntry.fold]
|
||||
/// 한 벌을 쓴다. 한 장 안에서 못 붙는 경계는 화면이 같은 규칙으로 이어 붙인다
|
||||
/// (당일 Zap 이 한 장 크기를 넘으면 장이 갈린다).
|
||||
///
|
||||
/// 횟수는 원장 행의 `count` 를 합산한다. `fn-eclipse-defend` 가 `count` 를 묶어
|
||||
/// 받으므로 행 하나가 여러 번분일 수 있다. **필드가 없는 행은 한 번으로 센다** —
|
||||
/// 지금 배포된 함수가 `count` 를 남기지 않아 예전 행에는 값이 없고, 그 경우
|
||||
/// 실제보다 작게 나올 수 있다. 함수 쪽에서 `count` 를 남기기 시작하면 이 코드는
|
||||
/// 고치지 않고 값이 정확해진다.
|
||||
List<PopLedgerEntry> _foldZap(List<_LedgerRow> rows) {
|
||||
final folded = <PopLedgerEntry>[];
|
||||
for (final row in rows) {
|
||||
final entry = row.reasonCode == 'ECLIPSE_DEFENSE'
|
||||
? _withRepeat(row.entry, row.count)
|
||||
: row.entry;
|
||||
final last = folded.isEmpty ? null : folded.last;
|
||||
if (last != null && last.foldsWith(entry)) {
|
||||
folded[folded.length - 1] = last.fold(entry);
|
||||
continue;
|
||||
}
|
||||
folded.add(entry);
|
||||
}
|
||||
return folded;
|
||||
}
|
||||
|
||||
PopLedgerEntry _withRepeat(PopLedgerEntry entry, int count) => PopLedgerEntry(
|
||||
at: entry.at,
|
||||
title: entry.title,
|
||||
type: entry.type,
|
||||
kind: entry.kind,
|
||||
txType: entry.txType,
|
||||
delta: entry.delta,
|
||||
balanceAfter: entry.balanceAfter,
|
||||
repeatCount: count,
|
||||
cursor: entry.cursor,
|
||||
);
|
||||
|
||||
/// 서버가 시각을 아직 채우지 않은 문서의 기본값. `DateTime.now()` 를 쓰면
|
||||
/// 목록을 새로 고칠 때마다 순서가 바뀐다.
|
||||
final DateTime _epoch = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
@@ -1,15 +1,534 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/ui/tab_placeholder.dart';
|
||||
import '../../core/data/backend.dart';
|
||||
import '../../core/format.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/push/push_messaging.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import '../auth/auth.dart';
|
||||
import '../auth/auth_repository.dart';
|
||||
import '../campaign/campaign_data.dart' show CampaignRepository;
|
||||
import 'my_data.dart';
|
||||
import 'my_lists_screen.dart';
|
||||
import 'profile_edit_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'tier_screens.dart';
|
||||
import 'widgets/my_cards.dart';
|
||||
import 'widgets/profile_card.dart';
|
||||
|
||||
/// MY 탭 루트 — S-M-HOME 마이 페이지.
|
||||
class MyScreen extends StatelessWidget {
|
||||
const MyScreen({super.key});
|
||||
/// MY 탭 루트 — 화면 대조 원장 F6 · Figma `373:632962`.
|
||||
///
|
||||
/// 명세 `549:22058`(프로필 카드 오버레이) — *프로필 사진 · 닉네임 · 티어 · 인포메이션 ·*
|
||||
/// *누적 기여 그래프 · 누적 걸음 · 누적 기여 POP · 완료 퀘스트 건수 · 공유 4종*
|
||||
class MyScreen extends StatefulWidget {
|
||||
const MyScreen({
|
||||
super.key,
|
||||
required this.repository,
|
||||
this.linker,
|
||||
this.push,
|
||||
this.auth,
|
||||
this.campaigns,
|
||||
});
|
||||
|
||||
final MyRepository repository;
|
||||
|
||||
/// 「내 캠페인」이 읽는 저장소. null 이면 표본.
|
||||
final CampaignRepository? campaigns;
|
||||
|
||||
/// 설정 > SNS 연동으로 그대로 내려간다. null 이면 설정 화면이 표본을 쓴다.
|
||||
final AccountLinker? linker;
|
||||
|
||||
/// 설정 > 푸시 알림 설정으로 내려간다. 목 모드에서는 null 이고, 그때 설정 화면은
|
||||
/// 권한 재요청 버튼을 그리지 않는다(요청할 플러그인이 없다).
|
||||
final PushMessaging? push;
|
||||
|
||||
/// 설정 > 로그아웃 배선. null 이면 확인 모달만 띄운다.
|
||||
final AuthRepository? auth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const TabPlaceholder(
|
||||
tab: 'MY',
|
||||
screenId: 'S-M-HOME',
|
||||
screenName: '마이 페이지',
|
||||
State<MyScreen> createState() => _MyScreenState();
|
||||
}
|
||||
|
||||
class _MyScreenState extends State<MyScreen> {
|
||||
MyProfile? _profile;
|
||||
FocusStatus? _focus;
|
||||
bool _claiming = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final profile = await widget.repository.profile();
|
||||
// 구독 조회 실패로 MY 전체가 멈추지 않게 한다 — 카드만 빠진다.
|
||||
final focus = await widget.repository.focus().catchError((_) => null);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_profile = profile;
|
||||
_focus = focus;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Focus 오늘 몫 받기(원장 B-04). 지급은 서버가 하고 화면은 결과만 알린다.
|
||||
Future<void> _claimFocus() async {
|
||||
if (_claiming) return;
|
||||
setState(() => _claiming = true);
|
||||
String message;
|
||||
try {
|
||||
final granted = await widget.repository.claimFocusDaily();
|
||||
message = context.l10n.focusClaimed(formatPop(granted));
|
||||
} on BackendException catch (error) {
|
||||
message = error.message;
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _claiming = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(behavior: SnackBarBehavior.floating, content: Text(message)),
|
||||
);
|
||||
await _load();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final profile = _profile;
|
||||
if (profile == null) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Scaffold(
|
||||
body: SafeArea(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: NestedScrollView(
|
||||
headerSliverBuilder: (context, _) => [
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
// Top Navigation — 높이 54 · 안쪽 6/16 · 「MY」 ABeeZee 20 · 아이콘 칸 42.
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'MY',
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontSize: 20,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_NavIcon(
|
||||
tooltip: context.l10n.myProfileCardTooltip,
|
||||
icon: AppIcons.share,
|
||||
// 사본 `lucide/share-2` 는 20 칸(설정 24 와 다르다).
|
||||
size: 20,
|
||||
onTap: () => _showCard(profile),
|
||||
),
|
||||
_NavIcon(
|
||||
tooltip: context.l10n.settingsTitle,
|
||||
icon: AppIcons.settings,
|
||||
onTap: () => _push(
|
||||
SettingsScreen(
|
||||
repository: widget.repository,
|
||||
linker: widget.linker,
|
||||
push: widget.push,
|
||||
auth: widget.auth,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_MyHeader(
|
||||
profile: profile,
|
||||
onCard: () => _showCard(profile),
|
||||
onPhoto: () => _changePhoto(profile),
|
||||
onEdit: () async {
|
||||
await _push(
|
||||
ProfileEditScreen(
|
||||
repository: widget.repository,
|
||||
nickname: profile.nickname,
|
||||
),
|
||||
);
|
||||
await _load();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
MyTierCard(
|
||||
profile: profile,
|
||||
onInfo: () => _push(const TierInfoScreen()),
|
||||
onDetail: () => _push(
|
||||
TierDetailScreen(tiers: profile.planetTiers),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
MyStatsCard(profile: profile),
|
||||
if (_focus case final focus?) ...[
|
||||
const SizedBox(height: 8),
|
||||
_FocusCard(
|
||||
focus: focus,
|
||||
busy: _claiming,
|
||||
onClaim: _claimFocus,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: MyTabBar(
|
||||
tabs: [
|
||||
context.l10n.myTabPlanets,
|
||||
context.l10n.myTabActivity,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
body: TabBarView(
|
||||
children: [
|
||||
ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
for (final (i, img) in const [
|
||||
'assets/figma/my-planet-1.png',
|
||||
'assets/figma/my-planet-2.png',
|
||||
'assets/figma/my-planet-3.png',
|
||||
].indexed) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Container(
|
||||
// 칸 109 안에 행성 88 — 안쪽 10.5.
|
||||
padding: const EdgeInsets.all(10.5),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2C2C2C),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppRadius.md,
|
||||
),
|
||||
),
|
||||
child: Image.asset(img, fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// 시안 `373:633440` — 카드 하나에 「인사이드」 · 「캠페인」 두 줄.
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: MyColors.card,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_Row(
|
||||
label: context.l10n.myInside,
|
||||
onTap: () => _push(
|
||||
const MyListsScreen(kind: MyListKind.inside),
|
||||
),
|
||||
),
|
||||
_Row(
|
||||
label: context.l10n.myCampaign,
|
||||
onTap: () => _push(
|
||||
MyListsScreen(
|
||||
kind: MyListKind.campaign,
|
||||
campaigns: widget.campaigns,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 카메라 배지 → 프로필 수정(시안 `374:15479`). 고른 사진을 바로 올린다.
|
||||
Future<void> _changePhoto(MyProfile profile) async {
|
||||
final photo = await Navigator.of(context, rootNavigator: true)
|
||||
.push<PickedPhoto>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
ProfilePhotoScreen(current: _MyHeader._avatarOf(profile)),
|
||||
),
|
||||
);
|
||||
if (photo == null || !mounted) return;
|
||||
try {
|
||||
await widget.repository.uploadProfilePhoto(photo);
|
||||
} on PhotoRejected catch (error) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(error.message)));
|
||||
return;
|
||||
}
|
||||
await _load();
|
||||
}
|
||||
|
||||
Future<void> _push(Widget screen) => Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).push(MaterialPageRoute<void>(builder: (_) => screen));
|
||||
|
||||
/// 프로필 카드 오버레이 — 시안 `374:15738` · 명세 `549:22058`. 공유 4종이 붙는다.
|
||||
Future<void> _showCard(MyProfile profile) => showDialog<void>(
|
||||
context: context,
|
||||
// 시안 `374:15738` — 바탕 #101010 · 좌우 20 · 모서리 24.
|
||||
builder: (context) => Dialog(
|
||||
backgroundColor: const Color(0xFF101010),
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
child: ProfileCard(
|
||||
profile: profile,
|
||||
onShare: (target) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(this.context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.myShareLater(target)),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Top Navigation 아이콘 칸 42×42 · 아이콘 24.
|
||||
class _NavIcon extends StatelessWidget {
|
||||
const _NavIcon({
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
this.size = 24,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final ImageProvider icon;
|
||||
final VoidCallback onTap;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => IconButton(
|
||||
tooltip: tooltip,
|
||||
onPressed: onTap,
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
constraints: const BoxConstraints.tightFor(width: 42, height: 42),
|
||||
icon: ImageIcon(icon, size: size, color: AppColors.onCosmic),
|
||||
);
|
||||
}
|
||||
|
||||
/// 상단 프로필 — 시안 MY: 지름 80 원형 사진 + 카메라 배지 30 · 닉네임 20/600 + 연필 14.
|
||||
class _MyHeader extends StatelessWidget {
|
||||
const _MyHeader({
|
||||
required this.profile,
|
||||
required this.onEdit,
|
||||
required this.onCard,
|
||||
required this.onPhoto,
|
||||
});
|
||||
|
||||
final MyProfile profile;
|
||||
final VoidCallback onEdit;
|
||||
|
||||
/// 카메라 배지 → 프로필 사진 수정.
|
||||
final VoidCallback onPhoto;
|
||||
|
||||
/// 사진을 누르면 프로필 카드 오버레이(시안 `374:15738`)가 뜬다.
|
||||
final VoidCallback onCard;
|
||||
|
||||
/// 올린 사진이 있으면 그것을, 없으면 시안 표본을 그린다.
|
||||
static ImageProvider<Object> _avatarOf(MyProfile profile) {
|
||||
final url = profile.photoUrl;
|
||||
return url == null || url.isEmpty
|
||||
? const AssetImage('assets/figma/avatar-sample.png')
|
||||
: NetworkImage(url);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: onCard,
|
||||
child: SizedBox(
|
||||
width: 80.5,
|
||||
height: 80,
|
||||
child: Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 40,
|
||||
backgroundColor: AppColors.cosmicSurfaceRaised,
|
||||
backgroundImage: _avatarOf(profile),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: context.l10n.myEditPhoto,
|
||||
child: GestureDetector(
|
||||
onTap: onPhoto,
|
||||
child: Image.asset(
|
||||
'assets/figma/my-camera-badge.png',
|
||||
width: 30,
|
||||
height: 30,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
GestureDetector(
|
||||
onTap: onEdit,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(profile.nickname, style: myText(20, FontWeight.w600)),
|
||||
const SizedBox(width: 8),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: context.l10n.myEditProfile,
|
||||
child: const ImageIcon(
|
||||
AssetImage('assets/figma/icon-pencil.png'),
|
||||
size: 14,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 활동 탭의 한 줄 — 글자 16/600 · 끝 chevron. 줄 높이 24 · 간격 24.
|
||||
/// Focus 구독 혜택 카드 — 원장 B-04 *일 40 기여 POP · 당일 Claim · 미수령 누적 없음*.
|
||||
///
|
||||
/// Figma 에는 이 Claim 자리가 없다(퀘스트 완료 화면의 「광고 제거 보상 · 퀘스트 보상 × 2」
|
||||
/// `518:88748` 은 B-04 가 대체한 과거 안). 반영 화면이 `MY` 로 정해져 있어 통계 카드 아래에 둔다.
|
||||
/// 구독 중일 때만 보인다.
|
||||
class _FocusCard extends StatelessWidget {
|
||||
const _FocusCard({required this.focus, required this.busy, required this.onClaim});
|
||||
|
||||
final FocusStatus focus;
|
||||
final bool busy;
|
||||
final VoidCallback onClaim;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final e = focus.expiresAt;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: MyColors.card,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.focusActive,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n.focusUntil(
|
||||
'${e.year}.${e.month.toString().padLeft(2, '0')}.${e.day.toString().padLeft(2, '0')}',
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 12,
|
||||
color: AppColors.onCosmicMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: focus.claimedToday || busy ? null : onClaim,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.cosmicAccent,
|
||||
foregroundColor: AppColors.onCosmicAccent,
|
||||
),
|
||||
child: Text(
|
||||
focus.claimedToday
|
||||
? context.l10n.focusClaimedToday
|
||||
: context.l10n.focusClaimToday,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Row extends StatelessWidget {
|
||||
const _Row({required this.label, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(label, style: myText(16, FontWeight.w600))),
|
||||
const MyChevron(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import 'my_data.dart';
|
||||
import 'widgets/my_cards.dart';
|
||||
|
||||
/// 「2026.04.30」 — 시안 공지 날짜 표기.
|
||||
String _date(DateTime at) =>
|
||||
'${at.year}.${at.month.toString().padLeft(2, '0')}.${at.day.toString().padLeft(2, '0')}';
|
||||
|
||||
/// 머리 세 줄 — 「공지」 13/500 분홍 · 제목 15/600 · 날짜 12/600 #777(간격 8 · 12).
|
||||
class _NoticeHead extends StatelessWidget {
|
||||
const _NoticeHead({required this.notice});
|
||||
|
||||
final NoticeItem notice;
|
||||
|
||||
static TextStyle _s(double size, FontWeight w, Color c) => TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: size,
|
||||
fontWeight: w,
|
||||
height: 1,
|
||||
color: c,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.noticeBadge,
|
||||
style: _s(13, FontWeight.w500, AppColors.cosmicAccent),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(notice.title, style: _s(15, FontWeight.w600, AppColors.onCosmic)),
|
||||
const SizedBox(height: 12),
|
||||
Text(_date(notice.at), style: _s(12, FontWeight.w600, MyColors.muted)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 공지사항 — 화면 대조 원장 F6 · Figma `357:224574`: 카드 #19191c · 모서리 16 · 안쪽 20/16 · 간격 12.
|
||||
class NoticeListScreen extends StatefulWidget {
|
||||
const NoticeListScreen({super.key, required this.repository});
|
||||
|
||||
final MyRepository repository;
|
||||
|
||||
@override
|
||||
State<NoticeListScreen> createState() => _NoticeListScreenState();
|
||||
}
|
||||
|
||||
class _NoticeListScreenState extends State<NoticeListScreen> {
|
||||
List<NoticeItem>? _notices;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.repository.notices().then((n) {
|
||||
if (mounted) setState(() => _notices = n);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final notices = _notices;
|
||||
if (notices == null) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(context.l10n.noticeTitle)),
|
||||
body: notices.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
context.l10n.noticeEmpty,
|
||||
style: myText(14, FontWeight.w500),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: notices.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, i) {
|
||||
final n = notices[i];
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => NoticeDetailScreen(notice: n),
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 20,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: MyColors.card,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: _NoticeHead(notice: n),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 공지사항 상세 — Figma `357:224775`: 머리 · 구분선 #313131 · 본문 14/400 행간 140%.
|
||||
class NoticeDetailScreen extends StatelessWidget {
|
||||
const NoticeDetailScreen({super.key, required this.notice});
|
||||
|
||||
final NoticeItem notice;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: Text(context.l10n.noticeDetailTitle)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_NoticeHead(notice: notice),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 1, child: ColoredBox(color: Color(0xFF313131))),
|
||||
const SizedBox(height: 18),
|
||||
Text(notice.body, style: myText(14, FontWeight.w400)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_radio.dart';
|
||||
import '../../core/monetization/store.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import 'my_data.dart';
|
||||
import 'widgets/my_cards.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 서비스 결제 내역 — 화면 대조 원장 F6 · Figma `374:15185`.
|
||||
///
|
||||
/// 명세 `554:23841` — *결제 일시 · 결제 아이템명 · 결제 아이템 가격 · 최신순 소팅*
|
||||
class PaymentHistoryScreen extends StatefulWidget {
|
||||
const PaymentHistoryScreen({super.key, required this.repository});
|
||||
|
||||
final MyRepository repository;
|
||||
|
||||
@override
|
||||
State<PaymentHistoryScreen> createState() => _PaymentHistoryScreenState();
|
||||
}
|
||||
|
||||
class _PaymentHistoryScreenState extends State<PaymentHistoryScreen> {
|
||||
List<PaymentEntry>? _payments;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final payments = await widget.repository.payments();
|
||||
if (mounted) setState(() => _payments = payments);
|
||||
}
|
||||
|
||||
Future<void> _restore() async {
|
||||
try {
|
||||
await Store.instance?.restore();
|
||||
} on Exception {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text(context.l10n.storeConnectFailed),
|
||||
));
|
||||
return;
|
||||
}
|
||||
await _load();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final payments = _payments;
|
||||
if (payments == null) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.settingsServicePayments),
|
||||
actions: [
|
||||
// 구매 복원 — 재설치 · 기기 변경 뒤 Focus 구독을 되살린다. 스토어가 돌려준
|
||||
// 구매는 서버가 거래 ID 로 멱등 검증하므로 두 번 지급되지 않는다.
|
||||
if (Store.instance != null)
|
||||
TextButton(onPressed: _restore, child: Text(context.l10n.payRestore)),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: payments.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
context.l10n.payEmpty,
|
||||
style: myText(14, FontWeight.w500),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: payments.length,
|
||||
itemBuilder: (context, i) {
|
||||
final p = payments[i];
|
||||
// 시안 `374:15185` — 줄 높이 58 · 안쪽 20/16 · 날짜 14/500 #777 · 상품 16/600 · 금액 18/800.
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () async {
|
||||
await Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PaymentDetailScreen(
|
||||
paymentId: p.id,
|
||||
repository: widget.repository,
|
||||
),
|
||||
),
|
||||
);
|
||||
await _load();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 20,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${p.at.month.toString().padLeft(2, '0')}.${p.at.day.toString().padLeft(2, '0')}',
|
||||
style: myText(
|
||||
14,
|
||||
FontWeight.w500,
|
||||
color: MyColors.muted,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
// 결제 완료가 아니면 상태를 붙인다(명세 `554:23961` 다섯 상태).
|
||||
p.state == PaymentState.paid
|
||||
? p.itemName
|
||||
: '${p.itemName} · ${p.state.label}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: myText(16, FontWeight.w600, height: 1),
|
||||
),
|
||||
),
|
||||
Text('₩ ${_won(p.price)}', style: _amount),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 금액 18/800 — 시안 결제 내역 · 상세.
|
||||
const _amount = TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1,
|
||||
color: AppColors.onCosmic,
|
||||
);
|
||||
|
||||
/// 결제 내역 상세 — Figma `506:14308`.
|
||||
///
|
||||
/// 명세 `554:23961` — *결제 아이템명 · 결제 번호 · 일시 · 방식 · 상태 · 금액*.
|
||||
/// 상태는 다섯이며 **결제 완료일 때만 환불을 신청**할 수 있다.
|
||||
class PaymentDetailScreen extends StatefulWidget {
|
||||
const PaymentDetailScreen({
|
||||
super.key,
|
||||
required this.paymentId,
|
||||
required this.repository,
|
||||
});
|
||||
|
||||
final String paymentId;
|
||||
final MyRepository repository;
|
||||
|
||||
/// 시안 `506:14308` 「환불 안내」 문구 그대로.
|
||||
static String get refundGuide => appL10n.payRefundGuide;
|
||||
|
||||
@override
|
||||
State<PaymentDetailScreen> createState() => _PaymentDetailScreenState();
|
||||
}
|
||||
|
||||
class _PaymentDetailScreenState extends State<PaymentDetailScreen> {
|
||||
PaymentEntry? _payment;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final payment = await widget.repository.payment(widget.paymentId);
|
||||
if (mounted) setState(() => _payment = payment);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = _payment;
|
||||
if (p == null) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
// 시안 `506:14308`(환불 가능) · `571:18672`(환불 불가 — 회색 「환불 불가」).
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(context.l10n.payDetailTitle)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(
|
||||
p.itemName,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const SizedBox(
|
||||
height: 1,
|
||||
child: ColoredBox(color: AppColors.onCosmicMuted),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_Row(label: context.l10n.payNumber, value: p.id),
|
||||
_Row(
|
||||
label: context.l10n.payDate,
|
||||
value:
|
||||
'${p.at.year}.${p.at.month.toString().padLeft(2, '0')}.${p.at.day.toString().padLeft(2, '0')}',
|
||||
),
|
||||
_Row(label: context.l10n.payMethod, value: p.method),
|
||||
_Row(label: context.l10n.payStatus, value: p.state.label),
|
||||
_Row(label: context.l10n.payAmount, value: '₩ ${_won(p.price)}', amount: true),
|
||||
const SizedBox(height: 8),
|
||||
Text(context.l10n.payRefundInfo, style: _guide),
|
||||
const SizedBox(height: 6),
|
||||
Text(PaymentDetailScreen.refundGuide, style: _guide),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
minimum: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: FilledButton(
|
||||
onPressed: p.state.canRequestRefund ? () => _requestRefund(p) : null,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.cosmicAccent,
|
||||
foregroundColor: AppColors.onCosmicAccent,
|
||||
disabledBackgroundColor: const Color(0xFF2C2C2C),
|
||||
disabledForegroundColor: AppColors.onCosmicMuted,
|
||||
),
|
||||
child: Text(
|
||||
p.state.canRequestRefund
|
||||
? context.l10n.payRefundApply
|
||||
: context.l10n.payRefundUnavailable,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _requestRefund(PaymentEntry payment) async {
|
||||
final reason = await Navigator.of(context, rootNavigator: true)
|
||||
.push<String>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => RefundRequestScreen(
|
||||
paymentId: payment.id,
|
||||
repository: widget.repository,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (reason == null || !mounted) return;
|
||||
await _load();
|
||||
}
|
||||
}
|
||||
|
||||
/// 환불 요청 — Figma `506:14437`.
|
||||
///
|
||||
/// 명세 `554:23972` — *요청 디폴트 비활성화, 환불 사유 선택 시 활성화*
|
||||
/// 명세 `554:23978` — *디폴트 선택 X · 단일 선택*
|
||||
/// 명세 `554:24008` — *확인 시 결제 완료 → 환불 요청으로 update*
|
||||
class RefundRequestScreen extends StatefulWidget {
|
||||
const RefundRequestScreen({
|
||||
super.key,
|
||||
required this.paymentId,
|
||||
required this.repository,
|
||||
});
|
||||
|
||||
final String paymentId;
|
||||
final MyRepository repository;
|
||||
|
||||
/// 환불 사유. Admin 에 등록될 목록의 표본이다.
|
||||
static List<String> reasonsOf(AppLocalizations l10n) => [
|
||||
l10n.refundReasonMistake,
|
||||
l10n.refundReasonExpectation,
|
||||
l10n.refundReasonError,
|
||||
l10n.refundReasonDuplicate,
|
||||
l10n.commonEtc,
|
||||
];
|
||||
|
||||
static List<String> get reasons => reasonsOf(appL10n);
|
||||
|
||||
@override
|
||||
State<RefundRequestScreen> createState() => _RefundRequestScreenState();
|
||||
}
|
||||
|
||||
class _RefundRequestScreenState extends State<RefundRequestScreen> {
|
||||
String? _reason;
|
||||
|
||||
// 시안 `506:14437` — 닫기 · 제목 「환불」 · 오른쪽 「요청」(비활성 #474747) ·
|
||||
// 머리 띠 #232325 「환불 사유」 · 줄 48(밑줄 #313131) · 라디오 20 · 글자 16/600 #d1d1d2.
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
tooltip: context.l10n.commonClose,
|
||||
icon: const ImageIcon(AppIcons.x, size: 24),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: Text(context.l10n.refundTitle),
|
||||
actions: [
|
||||
GestureDetector(
|
||||
// 디폴트 비활성화 · 사유 선택 시 활성화.
|
||||
onTap: _reason == null ? null : _confirm,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
child: Text(
|
||||
context.l10n.refundSubmit,
|
||||
style: myText(
|
||||
14,
|
||||
FontWeight.w600,
|
||||
height: 1,
|
||||
color: _reason == null
|
||||
? const Color(0xFF474747)
|
||||
: AppColors.cosmicAccent,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 디폴트 미선택 · 단수 선택.
|
||||
body: ListView(
|
||||
children: [
|
||||
Container(
|
||||
height: 32,
|
||||
color: const Color(0xFF232325),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
context.l10n.refundReason,
|
||||
style: myText(
|
||||
12,
|
||||
FontWeight.w600,
|
||||
color: const Color(0xFF909091),
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final reason in RefundRequestScreen.reasons)
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => setState(() => _reason = reason),
|
||||
child: Container(
|
||||
height: 48,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: Color(0xFF313131))),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
AppRadio(selected: _reason == reason),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
reason,
|
||||
style: myText(
|
||||
16,
|
||||
FontWeight.w600,
|
||||
color: const Color(0xFFD1D1D2),
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
/// 확인 — 시안 `506:14615` iOS 경고창 「정말 환불 신청을 할까요?」.
|
||||
Future<void> _confirm() async {
|
||||
final ok = await showCupertinoDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => CupertinoAlertDialog(
|
||||
title: Text(context.l10n.refundConfirm),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(context.l10n.commonCancel),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(context.l10n.commonConfirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true || !mounted) return;
|
||||
// 서버·Admin 에는 기준 언어(ko) 사유를 남긴다 — 화면 언어에 따라 원장 값이 갈리지 않게.
|
||||
await widget.repository.requestRefund(
|
||||
widget.paymentId,
|
||||
RefundRequestScreen.reasonsOf(
|
||||
koL10n,
|
||||
)[RefundRequestScreen.reasons.indexOf(_reason!)],
|
||||
);
|
||||
if (mounted) Navigator.of(context).pop(_reason);
|
||||
}
|
||||
}
|
||||
|
||||
class _Row extends StatelessWidget {
|
||||
const _Row({required this.label, required this.value, this.amount = false});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final bool amount;
|
||||
|
||||
/// 시안 — 라벨 14/600 #cdcdcd · 값 14/600 · 줄 24 · 간격 16.
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFCDCDCD),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
value,
|
||||
style: amount
|
||||
? _amount
|
||||
: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 환불 안내 12/400 #767676 행간 170%.
|
||||
const _guide = TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.7,
|
||||
color: AppColors.onCosmicMuted,
|
||||
);
|
||||
|
||||
/// 원화 표기. POP 과 다른 단위라 함수를 나눈다.
|
||||
String _won(int value) {
|
||||
final s = value.toString();
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
if (i > 0 && (s.length - i) % 3 == 0) buffer.write(',');
|
||||
buffer.write(s[i]);
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import 'my_data.dart';
|
||||
|
||||
/// 사진을 어디서 가져오는가 — Figma `374:15486` 「앨범 항목」과 카메라 칸.
|
||||
enum PhotoSource { gallery, camera }
|
||||
|
||||
/// 사진 한 장을 고르는 일. 화면이 이 함수를 주입받는다.
|
||||
///
|
||||
/// 플러그인을 화면 안에서 직접 부르면 위젯 테스트가 플랫폼 채널을 타서 돌지
|
||||
/// 않는다. 고르는 일만 밖으로 빼면 화면은 「골라진 사진」만 다루면 된다.
|
||||
typedef PhotoPicker = Future<PickedPhoto?> Function(PhotoSource source);
|
||||
|
||||
/// 기본 구현 — `image_picker`.
|
||||
///
|
||||
/// 앨범·카메라 권한 문구는 iOS `Info.plist` 의 `NSPhotoLibraryUsageDescription`·
|
||||
/// `NSCameraUsageDescription` 에 이미 들어가 있다(BUILD.md 「권한 사용 문구 6종」).
|
||||
/// 문구가 없으면 권한을 묻는 순간 앱이 종료되므로 여기서 다시 확인해 둔다.
|
||||
Future<PickedPhoto?> pickPhotoWithPlugin(PhotoSource source) async {
|
||||
final file = await ImagePicker().pickImage(
|
||||
source: source == PhotoSource.camera
|
||||
? ImageSource.camera
|
||||
: ImageSource.gallery,
|
||||
);
|
||||
if (file == null) return null;
|
||||
final bytes = await file.readAsBytes();
|
||||
return PickedPhoto(
|
||||
bytes: bytes,
|
||||
// 플러그인이 종류를 주지 않는 기기가 있어 확장자로 물러난다.
|
||||
contentType: file.mimeType ?? _typeOfName(file.name),
|
||||
name: file.name,
|
||||
);
|
||||
}
|
||||
|
||||
String _typeOfName(String name) {
|
||||
final lower = name.toLowerCase();
|
||||
if (lower.endsWith('.png')) return 'image/png';
|
||||
if (lower.endsWith('.webp')) return 'image/webp';
|
||||
if (lower.endsWith('.gif')) return 'image/gif';
|
||||
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
|
||||
// 모르는 확장자는 지어내지 않는다. `checkProfilePhoto` 가 형식으로 거른다.
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../home/home_screen.dart' show formatPop;
|
||||
import 'my_data.dart';
|
||||
import 'widgets/my_cards.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// POP 내역 — 화면 대조 원장 F6 · Figma `374:15090`.
|
||||
///
|
||||
/// 명세 `554:23714` — 현재 보유 POP
|
||||
/// 명세 `554:23732` — *디폴트 최신순으로 20개 노출 · 날짜 · 활동명 · 활동 타입 ·*
|
||||
/// *증감 POP 값 · 누적 POP 값*. Zap 은 건별이 아니라 **당일 합산 한 줄**이다.
|
||||
///
|
||||
/// 한 줄에 일반 POP / 기여 POP 을 함께 적는 것이 정책 원장 **B-01** 과 별첨 1 ②
|
||||
/// 「무료/유료 POP 원장 분리 유지」를 화면에서 보이는 지점이다.
|
||||
class PopHistoryScreen extends StatefulWidget {
|
||||
const PopHistoryScreen({super.key, required this.repository});
|
||||
|
||||
final MyRepository repository;
|
||||
|
||||
/// 명세가 정한 한 장의 크기.
|
||||
static const int pageSize = 20;
|
||||
|
||||
@override
|
||||
State<PopHistoryScreen> createState() => _PopHistoryScreenState();
|
||||
}
|
||||
|
||||
class _PopHistoryScreenState extends State<PopHistoryScreen> {
|
||||
MyProfile? _profile;
|
||||
final List<PopLedgerEntry> _entries = [];
|
||||
bool _loadingMore = false;
|
||||
|
||||
/// 더 읽을 것이 남았는가.
|
||||
///
|
||||
/// **장의 길이로 판정하지 않는다.** Zap 을 당일 한 줄로 접으므로 원장에서 20건을
|
||||
/// 읽어도 화면 줄이 그보다 적게 나온다. 길이로 끝을 판정하면 접힌 장 뒤에 남은
|
||||
/// 내역이 조용히 사라진다. 빈 장이 올 때까지 열어 둔다.
|
||||
bool _hasMore = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final profile = await widget.repository.profile();
|
||||
final entries = await widget.repository.popHistory(
|
||||
limit: PopHistoryScreen.pageSize,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_profile = profile;
|
||||
_entries
|
||||
..clear()
|
||||
..addAll(entries);
|
||||
_hasMore = entries.isNotEmpty;
|
||||
});
|
||||
}
|
||||
|
||||
/// 다음 장. 커서는 마지막 줄이 들고 있는 [PopLedgerEntry.cursor] 다.
|
||||
///
|
||||
/// 표시용 시각을 그대로 쓰면 접힌 Zap 줄의 안쪽 행을 다시 읽어 같은 줄이 두 번 나온다.
|
||||
///
|
||||
/// **장 경계를 이어 붙인다.** 저장소는 한 장 안에서만 접으므로, 당일 Zap 이 한 장
|
||||
/// 크기를 넘으면 「Zap 기여」 줄이 장마다 하나씩 생긴다. 새 장의 첫 줄이 직전 줄과
|
||||
/// 접히는 짝이면 여기서 합친다. 판정과 합치는 규칙은 저장소와 같은 것을 쓴다.
|
||||
Future<void> _more() async {
|
||||
final cursor = _entries.isEmpty ? null : _entries.last.cursor;
|
||||
if (_loadingMore || !_hasMore || cursor == null) return;
|
||||
setState(() => _loadingMore = true);
|
||||
final next = await widget.repository.popHistory(
|
||||
limit: PopHistoryScreen.pageSize,
|
||||
after: cursor,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_append(next);
|
||||
_hasMore = next.isNotEmpty;
|
||||
_loadingMore = false;
|
||||
});
|
||||
}
|
||||
|
||||
/// 새 장을 목록 끝에 붙인다. 경계에서 접힐 짝이면 한 줄로 합친다.
|
||||
void _append(List<PopLedgerEntry> next) {
|
||||
if (next.isEmpty) return;
|
||||
if (_entries.isNotEmpty && _entries.last.foldsWith(next.first)) {
|
||||
_entries[_entries.length - 1] = _entries.last.fold(next.first);
|
||||
_entries.addAll(next.skip(1));
|
||||
return;
|
||||
}
|
||||
_entries.addAll(next);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final profile = _profile;
|
||||
if (profile == null) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
// 시안 `374:15090` — 머리(#141414 · 위아래 60) 아래 #19191c 띠 8, 그 아래 목록(#141414).
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(context.l10n.myPop)),
|
||||
backgroundColor: MyColors.card,
|
||||
body: ListView.builder(
|
||||
// 0 은 보유 POP, 마지막 칸은 「더 보기」 자리다.
|
||||
itemCount:
|
||||
1 + (_entries.isEmpty ? 1 : _entries.length) + (_hasMore ? 1 : 0),
|
||||
itemBuilder: (context, i) {
|
||||
if (i == 0) return _Balance(pop: profile.balancePop);
|
||||
i -= 1;
|
||||
if (_entries.isEmpty && i == 0) {
|
||||
return ColoredBox(
|
||||
color: AppColors.cosmicVoid,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpace.s6),
|
||||
child: Center(
|
||||
child: Text(context.l10n.commonNoHistory, style: myText(14, FontWeight.w500)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (i >= _entries.length) {
|
||||
return ColoredBox(
|
||||
color: AppColors.cosmicVoid,
|
||||
child: Center(
|
||||
child: _loadingMore
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(AppSpace.s3),
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
: TextButton(onPressed: _more, child: Text(context.l10n.commonMore)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _EntryRow(entry: _entries[i]);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 보유 POP — 번개 20 + 「POP」 18/500 민트 · 값 36/900.
|
||||
class _Balance extends StatelessWidget {
|
||||
const _Balance({required this.pop});
|
||||
|
||||
final int pop;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
color: AppColors.cosmicVoid,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(vertical: 60),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/figma/icon-pop-small.png',
|
||||
width: 20,
|
||||
height: 20.8,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'POP',
|
||||
style: myText(18, FontWeight.w500, color: _mint, height: 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
height: 24,
|
||||
child: OverflowBox(
|
||||
maxHeight: 48,
|
||||
child: Text(
|
||||
formatPop(pop),
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 민트 #67d3a9 — 활동 타입 · 적립 값.
|
||||
const _mint = Color(0xFF67D3A9);
|
||||
|
||||
/// 한 줄 — 높이 79 · 안쪽 20/16 · 날짜 14/500 #777 · 활동명 16/600 · 타입 12 민트 ·
|
||||
/// 증감 18/800(적립 민트, 차감 흰색) · 누적 14/500 #777.
|
||||
///
|
||||
/// 타입 자리에 거래 종류와 POP 종류(일반/기여)를 함께 적는다(B-01).
|
||||
class _EntryRow extends StatelessWidget {
|
||||
const _EntryRow({required this.entry});
|
||||
|
||||
final PopLedgerEntry entry;
|
||||
|
||||
String get _date =>
|
||||
'${entry.at.month.toString().padLeft(2, '0')}.${entry.at.day.toString().padLeft(2, '0')}';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final up = entry.delta >= 0;
|
||||
return Container(
|
||||
color: AppColors.cosmicVoid,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_date,
|
||||
style: myText(
|
||||
14,
|
||||
FontWeight.w500,
|
||||
color: MyColors.muted,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
entry.displayTitle,
|
||||
style: myText(16, FontWeight.w600, height: 1),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
entry.displaySubtitle,
|
||||
style: myText(12, FontWeight.w400, color: _mint),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${up ? '+' : '-'}${formatPop(entry.delta.abs())}',
|
||||
style: myText(
|
||||
18,
|
||||
FontWeight.w800,
|
||||
color: up ? _mint : AppColors.onCosmic,
|
||||
height: 1,
|
||||
).copyWith(letterSpacing: 0),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
formatPop(entry.balanceAfter),
|
||||
style: myText(
|
||||
14,
|
||||
FontWeight.w500,
|
||||
color: MyColors.muted,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/tokens.dart';
|
||||
import 'my_data.dart';
|
||||
import 'photo_picker.dart';
|
||||
import 'widgets/my_cards.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 닉네임 수정 — 화면 대조 원장 F6 · 시안 `374:16029`(MY 연필에서 진입).
|
||||
///
|
||||
/// 명세 `549:22136` — *완료 디폴트 비활성화 · 변경 사항 있을 경우 활성화 ·*
|
||||
/// ***중복된 닉네임일 경우 닉네임 필드 아래 헬프 텍스트로 오류 메시지 노출***
|
||||
///
|
||||
/// 사진은 시안대로 MY 의 카메라 배지 → [ProfilePhotoScreen] 에서 바꾼다.
|
||||
class ProfileEditScreen extends StatefulWidget {
|
||||
const ProfileEditScreen({
|
||||
super.key,
|
||||
required this.nickname,
|
||||
required this.repository,
|
||||
});
|
||||
|
||||
final String nickname;
|
||||
final MyRepository repository;
|
||||
|
||||
@override
|
||||
State<ProfileEditScreen> createState() => _ProfileEditScreenState();
|
||||
}
|
||||
|
||||
class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
late final _nickname = TextEditingController(text: widget.nickname);
|
||||
String? _error;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 「팬」처럼 두 자 미만이면 완료가 잠기므로 예약어는 입력하는 즉시 안내한다.
|
||||
_nickname.addListener(
|
||||
() => setState(
|
||||
() => _error = nicknameReserved(_nickname.text)
|
||||
? context.l10n.nicknameReserved
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nickname.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 닉네임이 바뀌고 **길이 조건까지 맞는가**. 정책 원장 B-52 가 2~20자로
|
||||
/// 확정했고 검증 기준이 *20자 성공 · 21자 거부* 다. 길이를 화면에서 막지 않으면
|
||||
/// 완료를 눌러야 규칙이 거부하고, 사용자는 사유 없이 실패만 본다.
|
||||
bool get _changed {
|
||||
final value = _nickname.text.trim();
|
||||
return value != widget.nickname &&
|
||||
value.characters.length >= nicknameMinLength &&
|
||||
value.characters.length <= nicknameMaxLength;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: myTextActionBar(
|
||||
context,
|
||||
title: context.l10n.nicknameEdit,
|
||||
onDone: _changed && !_saving ? _save : null,
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// 시안 — 카드 #19191c · 모서리 16 · 높이 58 · 글자 18/500.
|
||||
Container(
|
||||
height: 58,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
alignment: Alignment.centerLeft,
|
||||
decoration: BoxDecoration(
|
||||
color: MyColors.card,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _nickname,
|
||||
// 21자째가 아예 입력되지 않는다. 입력은 받아 두고 완료에서 막으면
|
||||
// 무엇이 문제인지 알기 어렵다.
|
||||
maxLength: nicknameMaxLength,
|
||||
style: myText(18, FontWeight.w500, height: 1),
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
counterText: '',
|
||||
),
|
||||
),
|
||||
),
|
||||
// 명세 — 오류는 필드 아래 헬프 텍스트로.
|
||||
if (_error case final message?)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Text(
|
||||
message,
|
||||
style: myText(12, FontWeight.w500, color: AppColors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _save() async {
|
||||
if (nicknameReserved(_nickname.text.trim())) {
|
||||
setState(() => _error = context.l10n.nicknameReserved);
|
||||
return;
|
||||
}
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await widget.repository.updateNickname(_nickname.text.trim());
|
||||
if (mounted) Navigator.of(context).pop(true);
|
||||
} on NicknameTakenException {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_saving = false;
|
||||
_error = context.l10n.nicknameTaken;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 「취소 · 제목 · 완료」 머리 — 시안 `374:15479` · `374:16029`: 양쪽 글자 14/600,
|
||||
/// 완료는 잠겨 있으면 #474747.
|
||||
PreferredSizeWidget myTextActionBar(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required VoidCallback? onDone,
|
||||
}) => AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leadingWidth: 72,
|
||||
leading: _BarText(label: context.l10n.commonCancel, onTap: () => Navigator.of(context).pop()),
|
||||
title: Text(title),
|
||||
actions: [_BarText(label: context.l10n.commonDone, onTap: onDone)],
|
||||
);
|
||||
|
||||
class _BarText extends StatelessWidget {
|
||||
const _BarText({required this.label, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Semantics(
|
||||
button: true,
|
||||
enabled: onTap != null,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Center(
|
||||
widthFactor: 1,
|
||||
child: Text(
|
||||
label,
|
||||
style: myText(
|
||||
14,
|
||||
FontWeight.w600,
|
||||
height: 1,
|
||||
color: onTap == null
|
||||
? const Color(0xFF474747)
|
||||
: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 프로필 사진 설정 — Figma `374:15479` · `374:16029` · `374:15486` 「앨범 항목」.
|
||||
///
|
||||
/// 명세 `549:22105` — *완료 디폴트 비활성화 · 사진 선택 또는 사진 촬영 후 활성화*
|
||||
/// 명세 `549:22119` — *노출 영역 원형 고정 · 확대 축소, 드래그 제스처*
|
||||
/// 명세 `549:22129` — *이미지 최신순으로 소팅*
|
||||
///
|
||||
/// 앨범 목록을 앱이 직접 그리지 않고 기기 사진 선택기를 띄운다. 최신순 정렬과
|
||||
/// 앨범 탐색이 선택기 쪽의 일이라 앱이 사진 전체에 대한 접근을 받아 둘 필요가
|
||||
/// 없다. **확대·축소·드래그로 잘라내는 편집은 아직 없다** — 자르기는 별도
|
||||
/// 플러그인이 필요하고 이번 범위에 없다.
|
||||
class ProfilePhotoScreen extends StatefulWidget {
|
||||
const ProfilePhotoScreen({
|
||||
super.key,
|
||||
this.pickPhoto = pickPhotoWithPlugin,
|
||||
this.current,
|
||||
});
|
||||
|
||||
final PhotoPicker pickPhoto;
|
||||
|
||||
/// 지금 쓰는 사진. 새로 고르기 전까지 미리보기에 그린다.
|
||||
final ImageProvider? current;
|
||||
|
||||
@override
|
||||
State<ProfilePhotoScreen> createState() => _ProfilePhotoScreenState();
|
||||
}
|
||||
|
||||
class _ProfilePhotoScreenState extends State<ProfilePhotoScreen> {
|
||||
PickedPhoto? _selected;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selected = _selected;
|
||||
final preview = selected != null
|
||||
? MemoryImage(selected.bytes)
|
||||
: widget.current;
|
||||
// 시안 `374:15479` — 375 정사각 미리보기(원형 노출 영역) · 「앨범 항목」 줄 56 ·
|
||||
// 92 칸 격자(간격 2). 첫 칸은 카메라, 둘째 칸은 기기 앨범 선택기다.
|
||||
return Scaffold(
|
||||
appBar: myTextActionBar(
|
||||
context,
|
||||
title: context.l10n.myEditProfile,
|
||||
// 고르기 전까지 완료가 잠겨 있다.
|
||||
onDone: selected == null
|
||||
? null
|
||||
: () => Navigator.of(context).pop(selected),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ColoredBox(
|
||||
color: const Color(0xFF4D4D4D),
|
||||
child: ClipOval(
|
||||
child: preview == null
|
||||
? const ColoredBox(color: AppColors.cosmicSurfaceRaised)
|
||||
: Image(image: preview, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
||||
child: Text(context.l10n.albumItems, style: myText(16, FontWeight.w600, height: 1)),
|
||||
),
|
||||
// 시안 4×3 격자 — 카메라 · 앨범 선택기 다음 열 칸은 #000 빈 칸(사진 목록은 기기 선택기가 그린다).
|
||||
Wrap(
|
||||
spacing: 2,
|
||||
runSpacing: 2,
|
||||
children: [
|
||||
_Cell(
|
||||
label: context.l10n.takePhoto,
|
||||
color: const Color(0xFF1F1E1F),
|
||||
onTap: () => _pick(PhotoSource.camera),
|
||||
child: const ImageIcon(
|
||||
AssetImage('assets/figma/icon-camera.png'),
|
||||
size: 32,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
_Cell(
|
||||
label: context.l10n.pickFromAlbum,
|
||||
color: AppColors.onCosmic,
|
||||
onTap: () => _pick(PhotoSource.gallery),
|
||||
child: preview == null
|
||||
? const Icon(
|
||||
Icons.photo_library_outlined,
|
||||
color: Colors.black,
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: ClipOval(
|
||||
child: Image(image: preview, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
),
|
||||
for (var i = 0; i < 10; i++)
|
||||
const SizedBox.square(
|
||||
dimension: 92,
|
||||
child: ColoredBox(color: Colors.black),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_error case final message?)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
message,
|
||||
style: myText(12, FontWeight.w500, color: AppColors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 고른 그 자리에서 1차 검증한다. 업로드까지 가서 거부되면 사용자가 왜
|
||||
/// 안 되는지 모른 채 기다린다.
|
||||
Future<void> _pick(PhotoSource source) async {
|
||||
final photo = await widget.pickPhoto(source);
|
||||
if (photo == null || !mounted) return;
|
||||
try {
|
||||
checkProfilePhoto(photo);
|
||||
} on PhotoRejected catch (error) {
|
||||
setState(() {
|
||||
_selected = null;
|
||||
_error = error.message;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_selected = photo;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 격자 한 칸 92×92.
|
||||
class _Cell extends StatelessWidget {
|
||||
const _Cell({
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Semantics(
|
||||
button: true,
|
||||
label: label,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 92,
|
||||
height: 92,
|
||||
color: color,
|
||||
alignment: Alignment.center,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import 'my_data.dart';
|
||||
import 'widgets/my_cards.dart';
|
||||
|
||||
/// 푸시 알림 설정 — 화면 대조 원장 F6 · Figma `357:223444` · 정책 원장 B-77.
|
||||
///
|
||||
/// **토글은 5 개다.** 별첨 B 가 「서비스 필수 / 팬덤 이벤트 / AI Briefing /
|
||||
/// Community / Marketing」 5 개 구분으로 확정했고, Figma `554:24062` 의 6 종
|
||||
/// (이클립스·좋아요·댓글·퀘스트·캠페인·마케팅)은 그 안으로 들어간다. 공통 기준
|
||||
/// 2항 — Figma 의 과거 표기가 이후 확정된 정책보다 우선하지 않는다.
|
||||
///
|
||||
/// 두 상태를 나눈 Figma 명세는 그대로 지킨다.
|
||||
/// - `554:24049` — *디바이스 앱 설정의 알림이 **off** 되어 있을 경우 표시*
|
||||
/// - `554:24062` — *알림이 **ON** 되어 있을 경우 표시*
|
||||
///
|
||||
/// **권한 조회는 화면이 하지 않는다.** [deviceAllowed] 로 주입받아 두 갈래를 모두
|
||||
/// 그린다. 권한을 여기서 읽으면 위젯 테스트가 플러그인을 요구하고, 실제 판정
|
||||
/// 주체는 `PushMessaging` 하나여야 한다. 거부 상태의 재요청은
|
||||
/// [onRequestPermission] 이 맡고, 없으면 기기 설정 안내만 띄운다.
|
||||
class PushSettingsScreen extends StatefulWidget {
|
||||
const PushSettingsScreen({
|
||||
super.key,
|
||||
required this.repository,
|
||||
this.deviceAllowed = true,
|
||||
this.onRequestPermission,
|
||||
this.onSettingsChanged,
|
||||
});
|
||||
|
||||
final MyRepository repository;
|
||||
|
||||
/// 기기에서 FANUZU 알림이 켜져 있는가.
|
||||
final bool deviceAllowed;
|
||||
|
||||
/// 권한 재요청. 허용되면 true 를 돌려준다.
|
||||
final Future<bool> Function()? onRequestPermission;
|
||||
|
||||
/// 설정이 바뀐 직후 토픽 구독을 다시 맞추는 콜백.
|
||||
///
|
||||
/// **저장만으로는 발송이 멈추지 않는다.** 서버는 토픽으로만 보내므로, 문서에
|
||||
/// `false` 를 적어도 그 토픽을 구독한 채면 알림이 계속 온다(B-77 검증 기준
|
||||
/// 「OFF 카테고리 발송 0건」). 그래서 저장과 구독 갱신을 같은 자리에서 한다.
|
||||
final Future<void> Function(Map<PushTopic, bool> prefs)? onSettingsChanged;
|
||||
|
||||
@override
|
||||
State<PushSettingsScreen> createState() => _PushSettingsScreenState();
|
||||
}
|
||||
|
||||
class _PushSettingsScreenState extends State<PushSettingsScreen> {
|
||||
Map<PushTopic, bool>? _settings;
|
||||
bool _allowed = true;
|
||||
bool _requesting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_allowed = widget.deviceAllowed;
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final settings = await widget.repository.pushSettings();
|
||||
if (mounted) setState(() => _settings = settings);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: Text(context.l10n.settingsPush)),
|
||||
body: !_allowed
|
||||
? _blocked()
|
||||
: _settings == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _toggles(),
|
||||
);
|
||||
|
||||
/// 알림이 꺼져 있을 때 — 시안 `357:223444`. 토글을 그리지 않는다. 앱 안에서 켜도 소용이 없다.
|
||||
///
|
||||
/// 「설정하러 가기」는 **앱 안에서 한 번 더 물을 수 있으면** 시스템 다이얼로그를
|
||||
/// 다시 띄우고([onRequestPermission]), 영구 거부면 기기 설정 안내를 띄운다.
|
||||
Widget _blocked() => ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.pushDeviceOff,
|
||||
textAlign: TextAlign.center,
|
||||
style: myText(20, FontWeight.w400, height: 1.2).copyWith(
|
||||
fontFamily: AppFonts.display,
|
||||
fontFamilyFallback: const [AppFonts.body],
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.pushDeviceOffHint,
|
||||
textAlign: TextAlign.center,
|
||||
style: myText(
|
||||
14,
|
||||
FontWeight.w500,
|
||||
color: MyColors.muted,
|
||||
height: 1.2,
|
||||
).copyWith(letterSpacing: 0),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
MyGroup(
|
||||
children: [
|
||||
MyLinkRow(
|
||||
label: _requesting
|
||||
? context.l10n.pushRequesting
|
||||
: context.l10n.pushGoToSettings,
|
||||
onTap: _requesting
|
||||
? () {}
|
||||
: widget.onRequestPermission == null
|
||||
? () => _toast(context.l10n.pushOpeningDeviceSettings)
|
||||
: _retryPermission,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Future<void> _retryPermission() async {
|
||||
setState(() => _requesting = true);
|
||||
final granted = await widget.onRequestPermission!.call();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_requesting = false;
|
||||
_allowed = granted;
|
||||
});
|
||||
if (!granted) {
|
||||
_toast(context.l10n.pushEnableInDevice);
|
||||
}
|
||||
}
|
||||
|
||||
/// 알림이 켜져 있을 때 — 시안 `357:223611` 의 「기기 알림 설정 · 서비스 알림 설정」 두 구획.
|
||||
/// 줄은 B-77 확정 5 구분이다.
|
||||
Widget _toggles() {
|
||||
final settings = _settings!;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
children: [
|
||||
MyGroup(
|
||||
title: context.l10n.pushDeviceSection,
|
||||
children: [
|
||||
MyLinkRow(
|
||||
label: context.l10n.pushSoundVibration,
|
||||
onTap: () => _toast(context.l10n.pushOpeningDeviceSettings),
|
||||
),
|
||||
],
|
||||
),
|
||||
MyGroup(
|
||||
title: context.l10n.pushServiceSection,
|
||||
children: [
|
||||
for (final topic in PushTopic.values)
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_topicLabel(context.l10n, topic),
|
||||
style: myText(
|
||||
16,
|
||||
FontWeight.w600,
|
||||
height: 1,
|
||||
color: topic.locked
|
||||
? MyColors.muted
|
||||
: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: topic.locked
|
||||
? true
|
||||
: (settings[topic] ?? topic.defaultOn),
|
||||
activeThumbColor: AppColors.onCosmic,
|
||||
activeTrackColor: AppColors.cosmicAccent,
|
||||
inactiveThumbColor: const Color(0xFF3A3A3A),
|
||||
inactiveTrackColor: const Color(0xFF2C2C2C),
|
||||
trackOutlineColor: const WidgetStatePropertyAll(
|
||||
Colors.transparent,
|
||||
),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
// 서비스 필수는 끌 수 없다(B-77 「서비스 필수 외 4종 개별 OFF」).
|
||||
onChanged: topic.locked
|
||||
? null
|
||||
: (on) async {
|
||||
setState(() => settings[topic] = on);
|
||||
await widget.repository.setPush(topic, on);
|
||||
// 저장 뒤에 부른다 — 구독을 먼저 바꾸면 저장이 실패했을 때
|
||||
// 문서와 실제 구독이 갈린다.
|
||||
await widget.onSettingsChanged?.call(settings);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static String _topicLabel(AppLocalizations l10n, PushTopic t) => switch (t) {
|
||||
PushTopic.essential => l10n.pushTopicEssential,
|
||||
PushTopic.fandomEvent => l10n.pushTopicFandomEvent,
|
||||
PushTopic.briefing => l10n.pushTopicBriefing,
|
||||
PushTopic.community => l10n.pushTopicCommunity,
|
||||
PushTopic.marketing => l10n.pushTopicMarketing,
|
||||
};
|
||||
|
||||
void _toast(String message) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/push/push_messaging.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import '../auth/auth.dart';
|
||||
import '../auth/auth_repository.dart';
|
||||
import '../home/home_screen.dart' show formatPop;
|
||||
import 'my_data.dart';
|
||||
import 'notice_screens.dart';
|
||||
import 'payment_screens.dart';
|
||||
import 'pop_history_screen.dart';
|
||||
import 'push_settings_screen.dart';
|
||||
import 'support_screens.dart';
|
||||
import 'widgets/my_cards.dart';
|
||||
|
||||
/// 설정 — 화면 대조 원장 F6 · Figma `354:222008`.
|
||||
///
|
||||
/// 명세 `554:22634` — 계정 정보(닉네임 · 가입 이메일)
|
||||
/// 명세 `554:22797` — SNS 연동. *동일 이메일만 연동 가능*. 행의 「연결」에서 바로 제공자
|
||||
/// 시트를 띄운다(시안 F6-05~07 · 대표 결정 — 별도 연동 화면을 거치지 않는다).
|
||||
/// 명세 `554:22831` — 푸시 알림 · 차단 · 언어
|
||||
/// 명세 `554:22857` — 공지사항 · 1:1 문의접수 · 앱 버전
|
||||
/// 명세 `554:22903` — 로그아웃 · 서비스 탈퇴는 **안내 모달**을 거친다
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
SettingsScreen({
|
||||
super.key,
|
||||
required this.repository,
|
||||
AccountLinker? linker,
|
||||
this.push,
|
||||
this.auth,
|
||||
}) : linker =
|
||||
linker ??
|
||||
SampleAccountLinker(
|
||||
seed: const {
|
||||
AuthProviderKind.google: 'fan@gmail.com',
|
||||
AuthProviderKind.kakao: 'fan@kakao.com',
|
||||
},
|
||||
);
|
||||
|
||||
final MyRepository repository;
|
||||
|
||||
/// 푸시 수신 경계. 푸시 알림 설정 화면이 권한 상태와 재요청에 쓴다.
|
||||
final PushMessaging? push;
|
||||
|
||||
/// 로그아웃 배선. null 이면 확인 모달만 띄운다(표본·테스트 경로).
|
||||
final AuthRepository? auth;
|
||||
|
||||
/// 계정 연결 경계 — 별첨 B v1.4 「하나의 UID 에 복수 소셜 Provider」.
|
||||
final AccountLinker linker;
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
MyProfile? _profile;
|
||||
Map<AuthProviderKind, String?> _linked = const {};
|
||||
({String current, bool latest})? _version;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final profile = await widget.repository.profile();
|
||||
final version = await widget.repository.appVersion();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_profile = profile;
|
||||
_linked = widget.linker.linkedAccounts;
|
||||
_version = version;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final profile = _profile;
|
||||
if (profile == null) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
final version = _version;
|
||||
final l10n = context.l10n;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.settingsTitle)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
children: [
|
||||
MyGroup(
|
||||
title: l10n.settingsAccount,
|
||||
children: [
|
||||
_ValueRow(label: l10n.settingsNickname, value: profile.nickname),
|
||||
const MyDivider(),
|
||||
_ValueRow(label: l10n.settingsEmail, value: profile.email),
|
||||
],
|
||||
),
|
||||
MyGroup(
|
||||
title: l10n.settingsSns,
|
||||
children: [
|
||||
// 별첨 B v1.4 확정 4종만 건다. X 로그인은 1차 범위에서 빠졌다.
|
||||
for (final provider in _providerOrder)
|
||||
_SnsRow(
|
||||
provider: provider,
|
||||
email: _linked.containsKey(provider)
|
||||
? _linked[provider] ?? ''
|
||||
: null,
|
||||
onTap: () => _toggleLink(provider),
|
||||
),
|
||||
],
|
||||
),
|
||||
MyGroup(
|
||||
title: l10n.settingsPopHistory,
|
||||
children: [
|
||||
MyLinkRow(
|
||||
onTap: () =>
|
||||
_push(PopHistoryScreen(repository: widget.repository)),
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/figma/icon-pop-small.png',
|
||||
width: 20,
|
||||
height: 20.8,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
formatPop(profile.balancePop),
|
||||
style: myText(18, FontWeight.w800, height: 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
MyGroup(
|
||||
title: l10n.settingsPayments,
|
||||
children: [
|
||||
MyLinkRow(
|
||||
label: l10n.settingsServicePayments,
|
||||
onTap: () =>
|
||||
_push(PaymentHistoryScreen(repository: widget.repository)),
|
||||
),
|
||||
],
|
||||
),
|
||||
MyGroup(
|
||||
title: l10n.settingsService,
|
||||
children: [
|
||||
MyLinkRow(label: l10n.settingsPush, onTap: _openPushSettings),
|
||||
MyLinkRow(
|
||||
label: l10n.settingsBlocked,
|
||||
onTap: () =>
|
||||
_push(BlockedScreen(repository: widget.repository)),
|
||||
),
|
||||
// 시안은 현재 언어를 적고 아래 화살표를 둔다 — 누르면 언어 설정 시트.
|
||||
MyLinkRow(
|
||||
label: languageName(_currentLocale),
|
||||
down: true,
|
||||
onTap: _pickLanguage,
|
||||
),
|
||||
],
|
||||
),
|
||||
MyGroup(
|
||||
title: l10n.settingsInfo,
|
||||
children: [
|
||||
MyLinkRow(
|
||||
label: l10n.settingsNotice,
|
||||
onTap: () =>
|
||||
_push(NoticeListScreen(repository: widget.repository)),
|
||||
),
|
||||
MyLinkRow(
|
||||
label: l10n.settingsInquiry,
|
||||
onTap: () =>
|
||||
_push(InquiryScreen(repository: widget.repository)),
|
||||
),
|
||||
const MyDivider(),
|
||||
SizedBox(
|
||||
height: 24,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.settingsAppVersion,
|
||||
style: myText(16, FontWeight.w600, height: 1),
|
||||
),
|
||||
),
|
||||
// 명세 — 최신이면 「최신 버전」, 아니면 버전 번호.
|
||||
Text(
|
||||
version == null
|
||||
? '—'
|
||||
: version.latest
|
||||
? l10n.settingsLatest
|
||||
: version.current,
|
||||
style: myText(
|
||||
14,
|
||||
FontWeight.w500,
|
||||
color: const Color(0xFFCDCDCD),
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
MyGroup(
|
||||
children: [
|
||||
MyLinkRow(
|
||||
label: l10n.settingsLogout,
|
||||
color: MyColors.danger,
|
||||
onTap: () => _confirm(
|
||||
title: l10n.settingsLogoutConfirm,
|
||||
action: l10n.settingsLogout,
|
||||
// 푸시 정리를 **먼저** 한다. 보안 규칙이 `users/{uid}` 쓰기를
|
||||
// `isSelf(uid)` 로 막아, 로그아웃 뒤에는 이 기기 토큰을 지울 수 없다.
|
||||
onConfirm: () async {
|
||||
await widget.push?.clearOnSignOut();
|
||||
await widget.auth?.signOut();
|
||||
},
|
||||
),
|
||||
),
|
||||
const MyDivider(),
|
||||
MyLinkRow(
|
||||
label: l10n.settingsWithdraw,
|
||||
// 정책 §P-13 이용자 고지 — POP 잔액 소멸. 사본 팝업 틀에 본문 한 줄만 더한다.
|
||||
onTap: () => _confirm(
|
||||
title: l10n.settingsWithdrawConfirm,
|
||||
body: l10n.settingsWithdrawBody,
|
||||
action: l10n.settingsWithdraw,
|
||||
withdraw: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 시안 `354:222008` 의 목록 순서(Apple · Google · Kakao) 뒤에 원장 1차 로그인 4종의 LINE.
|
||||
static const _providerOrder = [
|
||||
AuthProviderKind.apple,
|
||||
AuthProviderKind.google,
|
||||
AuthProviderKind.kakao,
|
||||
AuthProviderKind.line,
|
||||
];
|
||||
|
||||
/// 표시 언어. 목록은 정책 원장 B-76 확정 5종이다([appLocales]).
|
||||
/// 앱 언어 상태([LocaleScope])가 없는 트리(단독 화면 테스트)에서는 이 화면 안에서만 바뀐다.
|
||||
Locale? _pickedLocale;
|
||||
|
||||
Locale get _currentLocale =>
|
||||
LocaleScope.maybeOf(context)?.locale ?? _pickedLocale ?? fallbackLocale;
|
||||
|
||||
/// 설정 행에서 바로 연결 · 해제한다(시안 F6-05~07 — 설정 위에 OS 제공자 시트가 뜬다).
|
||||
///
|
||||
/// 연결은 제공자 시트로 곧장 가고, 해제는 확인 팝업(시안 `554:23257`)을 거친다.
|
||||
/// 이메일이 다르면(E-AUTH-08 · [sameAccountIdentity]) 시안 `554:23103` 안내 팝업.
|
||||
Future<void> _toggleLink(AuthProviderKind provider) async {
|
||||
final connected = _linked.containsKey(provider);
|
||||
if (connected) {
|
||||
final l10n = context.l10n;
|
||||
final ok = await _confirm(
|
||||
title: l10n.settingsUnlinkConfirm,
|
||||
body: l10n.settingsUnlinkBody,
|
||||
action: l10n.settingsUnlink,
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
final outcome = connected
|
||||
? await widget.linker.unlink(provider)
|
||||
: await widget.linker.link(provider);
|
||||
if (!mounted) return;
|
||||
// 화면 상태를 추측하지 않고 연결 경계에 다시 물어본다 — 성공했다고 믿고
|
||||
// 로컬 값을 뒤집으면 서버가 거절한 연결이 붙은 것처럼 보인다.
|
||||
setState(() => _linked = widget.linker.linkedAccounts);
|
||||
switch (outcome) {
|
||||
case AuthFailed(code: 'E-AUTH-08'):
|
||||
await _notice(context.l10n.settingsSameEmailOnly);
|
||||
case AuthFailed(:final code, :final message):
|
||||
_toast('[$code] $message');
|
||||
case AuthNotConfigured(:final missing):
|
||||
_toast(
|
||||
context.l10n.settingsLinkNotReady(
|
||||
_snsName(context, provider),
|
||||
missing.join(' · '),
|
||||
),
|
||||
);
|
||||
case AuthSucceeded() || AuthCancelled():
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _toast(String message) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _push(Widget screen) async {
|
||||
await Navigator.of(
|
||||
context,
|
||||
).push<void>(MaterialPageRoute(builder: (_) => screen));
|
||||
if (mounted) await _load();
|
||||
}
|
||||
|
||||
/// 푸시 알림 설정 진입.
|
||||
///
|
||||
/// 권한 상태를 **들어가기 전에** 읽는다. 화면 안에서 읽으면 로딩 한 프레임 동안
|
||||
/// 토글이 보이고 곧 「꺼져 있습니다」로 바뀌어, 방금 끈 것처럼 보인다.
|
||||
/// 푸시 경계가 없으면(목 모드) 허용 상태로 두어 토글 동선은 그대로 확인된다.
|
||||
Future<void> _openPushSettings() async {
|
||||
final push = widget.push;
|
||||
final allowed = push == null || (await push.refreshStatus()).isAllowed;
|
||||
if (!mounted) return;
|
||||
await _push(
|
||||
PushSettingsScreen(
|
||||
repository: widget.repository,
|
||||
deviceAllowed: allowed,
|
||||
onRequestPermission: push == null
|
||||
? null
|
||||
: () async => (await push.requestPermission()).isAllowed,
|
||||
// 토글을 바꾸면 그 자리에서 토픽 구독까지 맞춘다. 앱 시작 시 한 번만
|
||||
// 맞추면 설정을 바꿔도 OFF 구분의 알림이 계속 온다.
|
||||
onSettingsChanged: push == null ? null : push.syncTopics,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 언어 설정 — 명세 `554:22831` *언어 설정 바텀시트 노출* · 시안 `357:224379`.
|
||||
Future<void> _pickLanguage() async {
|
||||
final picked = await showModalBottomSheet<Locale>(
|
||||
context: context,
|
||||
backgroundColor: const Color(0xFF101010),
|
||||
builder: (context) => _LanguageSheet(current: _currentLocale),
|
||||
);
|
||||
if (picked == null || !mounted) return;
|
||||
setState(() => _pickedLocale = picked);
|
||||
await LocaleScope.maybeOf(context)?.select(picked);
|
||||
}
|
||||
|
||||
/// 안내 팝업(확인 하나) — 시안 `554:23103`.
|
||||
Future<void> _notice(String message) => showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => _MyDialog(
|
||||
title: message,
|
||||
actions: [
|
||||
_DialogButton(
|
||||
label: context.l10n.commonConfirm,
|
||||
filled: true,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
/// 확인 팝업(취소 · 실행) — 시안 `554:24669` · `374:14364` · `554:23257`.
|
||||
Future<bool> _confirm({
|
||||
required String title,
|
||||
String? body,
|
||||
required String action,
|
||||
bool withdraw = false,
|
||||
Future<void> Function()? onConfirm,
|
||||
}) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => _MyDialog(
|
||||
title: title,
|
||||
body: body,
|
||||
actions: [
|
||||
_DialogButton(
|
||||
label: context.l10n.commonCancel,
|
||||
onTap: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
_DialogButton(
|
||||
label: action,
|
||||
filled: true,
|
||||
onTap: () => Navigator.of(context).pop(true),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true && onConfirm != null) {
|
||||
await onConfirm();
|
||||
} else if (ok == true && withdraw && mounted) {
|
||||
_toast(context.l10n.settingsWithdrawLater);
|
||||
}
|
||||
return ok ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 「닉네임 · BTS_Hong」 — 둘 다 16/600, 값은 #bbbbbb.
|
||||
class _ValueRow extends StatelessWidget {
|
||||
const _ValueRow({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
children: [
|
||||
Text(label, style: myText(16, FontWeight.w600, height: 1)),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.right,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: myText(16, FontWeight.w600, color: MyColors.value, height: 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 시안 표기 이름. 앱 공통 [AuthProviderKind.shortName] 은 한글(카카오)이라 따로 둔다.
|
||||
String _snsName(BuildContext context, AuthProviderKind p) => switch (p) {
|
||||
AuthProviderKind.apple => 'Apple',
|
||||
AuthProviderKind.google => 'Google',
|
||||
AuthProviderKind.kakao => 'Kakao',
|
||||
AuthProviderKind.line => 'LINE',
|
||||
AuthProviderKind.email => context.l10n.settingsEmailProvider,
|
||||
};
|
||||
|
||||
/// SNS 연동 한 줄 — 아이콘 20 · 이름 14/500 · 오른쪽 45×29 버튼(미연결 분홍 「연결」,
|
||||
/// 연결 흰 「연결해제」 · 시안 `374:14705`). 연결되어 있으면 아래에 제공자 이메일.
|
||||
class _SnsRow extends StatelessWidget {
|
||||
const _SnsRow({
|
||||
required this.provider,
|
||||
required this.email,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final AuthProviderKind provider;
|
||||
|
||||
/// null 이면 미연결. 연결됐지만 제공자가 이메일을 안 준 경우는 빈 문자열.
|
||||
final String? email;
|
||||
final VoidCallback onTap;
|
||||
|
||||
Widget get _icon => switch (provider) {
|
||||
AuthProviderKind.apple => const ImageIcon(
|
||||
AssetImage('assets/figma/brand-apple.png'),
|
||||
size: 20,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
AuthProviderKind.google => Image.asset(
|
||||
'assets/figma/brand-google.png',
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
AuthProviderKind.kakao => const ImageIcon(
|
||||
AssetImage('assets/figma/brand-kakao.png'),
|
||||
size: 20,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
// 시안에 LINE 아이콘이 없어(사본은 X 자리) 브랜드 색 말풍선으로 대신한다.
|
||||
_ => const Icon(Icons.chat_bubble, size: 18, color: BrandColors.line),
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connected = email != null;
|
||||
final action = connected
|
||||
? context.l10n.settingsDisconnect
|
||||
: context.l10n.settingsConnect;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 29,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 20, height: 20, child: Center(child: _icon)),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_snsName(context, provider),
|
||||
style: myText(14, FontWeight.w500),
|
||||
),
|
||||
),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: '${_snsName(context, provider)} $action',
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: connected
|
||||
? AppColors.onCosmic
|
||||
: AppColors.cosmicAccent,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xs),
|
||||
),
|
||||
child: Text(
|
||||
action,
|
||||
style: myText(
|
||||
12,
|
||||
FontWeight.w500,
|
||||
color: AppColors.onCosmicAccent,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (email case final e? when e.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(e, style: myText(14, FontWeight.w500)),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 팝업 — 바탕 #2d2d2d · 좌우 31.5 · 안쪽 24/16 · 제목 16/600 가운데 · 버튼 높이 48 간격 8.
|
||||
class _MyDialog extends StatelessWidget {
|
||||
const _MyDialog({required this.title, this.body, required this.actions});
|
||||
|
||||
final String title;
|
||||
final String? body;
|
||||
final List<Widget> actions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 31.5),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: myText(16, FontWeight.w600),
|
||||
),
|
||||
if (body case final b?) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
b,
|
||||
textAlign: TextAlign.center,
|
||||
style: myText(16, FontWeight.w500),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
for (final (i, a) in actions.indexed) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
Expanded(child: a),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _DialogButton extends StatelessWidget {
|
||||
const _DialogButton({
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.filled = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool filled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shape = RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
);
|
||||
final text = Text(
|
||||
label,
|
||||
style: myText(
|
||||
16,
|
||||
FontWeight.w600,
|
||||
height: 1,
|
||||
color: filled ? AppColors.onCosmicAccent : AppColors.onCosmic,
|
||||
),
|
||||
);
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
child: filled
|
||||
? FilledButton(
|
||||
onPressed: onTap,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
padding: EdgeInsets.zero,
|
||||
backgroundColor: AppColors.cosmicAccent,
|
||||
shape: shape,
|
||||
),
|
||||
child: text,
|
||||
)
|
||||
: OutlinedButton(
|
||||
onPressed: onTap,
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
padding: EdgeInsets.zero,
|
||||
side: const BorderSide(color: AppColors.onCosmic),
|
||||
shape: shape,
|
||||
),
|
||||
child: text,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 언어 설정 시트 — 시안 `357:224379`: 제목 + 닫기 · 언어 목록(고른 것에 분홍 체크) · 「선택」.
|
||||
class _LanguageSheet extends StatefulWidget {
|
||||
const _LanguageSheet({required this.current});
|
||||
|
||||
final Locale current;
|
||||
|
||||
@override
|
||||
State<_LanguageSheet> createState() => _LanguageSheetState();
|
||||
}
|
||||
|
||||
class _LanguageSheetState extends State<_LanguageSheet> {
|
||||
late Locale _picked = widget.current;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicOutline,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.languageSettingsTitle,
|
||||
style: myText(18, FontWeight.w600),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: context.l10n.commonClose,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const ImageIcon(
|
||||
AppIcons.x,
|
||||
size: 24,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final lang in appLocales)
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => setState(() => _picked = lang),
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
languageName(lang),
|
||||
style: myText(16, FontWeight.w500),
|
||||
),
|
||||
),
|
||||
if (lang == _picked)
|
||||
const Icon(
|
||||
Icons.check_circle_outline,
|
||||
size: 22,
|
||||
color: AppColors.cosmicAccent,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(_picked),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.cosmicAccent,
|
||||
foregroundColor: AppColors.onCosmicAccent,
|
||||
),
|
||||
child: Text(context.l10n.commonSelect),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/relative_time.dart';
|
||||
import 'my_data.dart';
|
||||
import 'widgets/my_cards.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 1:1 문의 목록 — 화면 대조 원장 F6 · Figma `373:634916` 「1:1 문의 접수」.
|
||||
///
|
||||
/// Figma 가 목록 · 상세 · 작성 세 프레임으로 나눠 두었다.
|
||||
/// - 목록 `373:634916` — 제목 · 상태(`접수`·`처리중`·`답변 완료`) · 날짜 · 「문의 작성」
|
||||
/// - 상세 `373:635153` — 제목 · 날짜 · 본문 · 답변(`376:14163`~`376:14165`)
|
||||
/// - 작성 `373:635052` — *제목을 입력해주세요.* · *문의할 내용을 작성해 주세요.*
|
||||
///
|
||||
/// 종전 구현은 분류 칩과 본문 한 칸이었는데, 그 분류는 Figma 에 없는 항목이었다.
|
||||
/// 공통 기준 2항이 화면 구조·UI 의 기준을 Figma 로 두었으므로 제목·본문으로 맞췄다.
|
||||
class InquiryScreen extends StatefulWidget {
|
||||
const InquiryScreen({super.key, required this.repository});
|
||||
|
||||
final MyRepository repository;
|
||||
|
||||
@override
|
||||
State<InquiryScreen> createState() => _InquiryScreenState();
|
||||
}
|
||||
|
||||
class _InquiryScreenState extends State<InquiryScreen> {
|
||||
List<InquiryItem>? _items;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final items = await widget.repository.myInquiries();
|
||||
if (mounted) setState(() => _items = items);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = _items;
|
||||
if (items == null) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
// 시안 `373:634845` — 카드 #19191c · 모서리 16 · 안쪽 20/16 · 간격 12 · 제목 15/600 ·
|
||||
// 상태 칩 #222222 모서리 4 · 날짜 12/600 #777 · 아래 「문의 작성」 주 버튼.
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(context.l10n.inquiryListTitle)),
|
||||
body: items.isEmpty
|
||||
? Center(
|
||||
child: Text(context.l10n.inquiryEmpty, style: myText(14, FontWeight.w500)),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, i) {
|
||||
final item = items[i];
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => InquiryDetailScreen(
|
||||
inquiry: item,
|
||||
repository: widget.repository,
|
||||
onChanged: _load,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 20,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: MyColors.card,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 24,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF222222),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppRadius.xs,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
item.status.label,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'${item.at.year}.${item.at.month.toString().padLeft(2, '0')}.${item.at.day.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1,
|
||||
color: MyColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// 시안 — 버튼 아래 16 을 홈 인디케이터 34 위에 둔다(minimum 은 인셋과 겹쳐 16 이 사라진다).
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: FilledButton(
|
||||
onPressed: _compose,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.cosmicAccent,
|
||||
foregroundColor: Colors.black,
|
||||
),
|
||||
child: Text(context.l10n.inquiryWrite),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _compose() async {
|
||||
final sent = await Navigator.of(context, rootNavigator: true).push<bool>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => InquiryComposeScreen(repository: widget.repository),
|
||||
),
|
||||
);
|
||||
if (sent == true) await _load();
|
||||
}
|
||||
}
|
||||
|
||||
/// 문의 상세 — Figma `373:635153`.
|
||||
///
|
||||
/// 「수정」은 접수 상태에서만 뜬다. 규칙이 `pending` 인 본인 문의의 제목·본문만
|
||||
/// 허용하므로(`firestore.rules` 의 `inquiries`), 답변이 올라간 뒤에 입구를 두면
|
||||
/// 누를 수는 있고 저장은 실패하는 화면이 된다.
|
||||
class InquiryDetailScreen extends StatelessWidget {
|
||||
const InquiryDetailScreen({
|
||||
super.key,
|
||||
required this.inquiry,
|
||||
required this.repository,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
final InquiryItem inquiry;
|
||||
final MyRepository repository;
|
||||
|
||||
/// 수정이 끝나면 목록을 다시 읽게 알린다.
|
||||
final VoidCallback? onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.inquiryDetail),
|
||||
actions: [
|
||||
if (inquiry.status == InquiryStatus.pending)
|
||||
TextButton(
|
||||
onPressed: () => _edit(context),
|
||||
child: Text(context.l10n.commonEdit),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
children: [
|
||||
Text(inquiry.title, style: t.titleMedium),
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
Text(
|
||||
'${inquiry.status.label} · ${relativeTime(inquiry.at)}',
|
||||
style: t.labelSmall,
|
||||
),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
Text(inquiry.body, style: t.bodyMedium),
|
||||
if (inquiry.answerBody case final answer?) ...[
|
||||
const Divider(height: AppSpace.s6),
|
||||
Text(context.l10n.inquiryAnswer, style: t.titleSmall),
|
||||
if (inquiry.answeredAt case final at?)
|
||||
Text(relativeTime(at), style: t.labelSmall),
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
Text(answer, style: t.bodyMedium),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _edit(BuildContext context) async {
|
||||
final saved = await Navigator.of(context, rootNavigator: true).push<bool>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
InquiryComposeScreen(repository: repository, edit: inquiry),
|
||||
),
|
||||
);
|
||||
if (saved != true) return;
|
||||
onChanged?.call();
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// 새 문의 작성 — Figma `373:635052` · 문의 수정 `554:24330`.
|
||||
///
|
||||
/// 두 프레임의 구조가 제목·본문·완료로 같아서 화면을 하나만 둔다. [edit] 이
|
||||
/// 있으면 수정이고, 없으면 새 문의다. 두 벌로 적으면 열 글자 규칙 같은 것이
|
||||
/// 한쪽에서만 고쳐진다.
|
||||
class InquiryComposeScreen extends StatefulWidget {
|
||||
const InquiryComposeScreen({super.key, required this.repository, this.edit});
|
||||
|
||||
final MyRepository repository;
|
||||
|
||||
/// 고칠 문의. 접수 상태의 본인 문의만 넘어온다.
|
||||
final InquiryItem? edit;
|
||||
|
||||
@override
|
||||
State<InquiryComposeScreen> createState() => _InquiryComposeScreenState();
|
||||
}
|
||||
|
||||
class _InquiryComposeScreenState extends State<InquiryComposeScreen> {
|
||||
late final _title = TextEditingController(text: widget.edit?.title ?? '');
|
||||
late final _body = TextEditingController(text: widget.edit?.body ?? '');
|
||||
bool _sending = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_title.addListener(() => setState(() {}));
|
||||
_body.addListener(() => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_title.dispose();
|
||||
_body.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _ready =>
|
||||
_title.text.trim().isNotEmpty && _body.text.trim().length >= 10;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
widget.edit == null
|
||||
? context.l10n.inquiryNew
|
||||
: context.l10n.inquiryEdit,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _ready && !_sending ? _send : null,
|
||||
child: Text(context.l10n.commonDone),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _title,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.commonTitle,
|
||||
hintText: context.l10n.commonTitleHint,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
TextField(
|
||||
controller: _body,
|
||||
maxLines: 8,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.inquiryBody,
|
||||
hintText: context.l10n.inquiryBodyHint,
|
||||
// 너무 짧으면 되물어야 해서 처리가 오래 걸린다.
|
||||
helperText: context.l10n.inquiryBodyHelper,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _send() async {
|
||||
final edit = widget.edit;
|
||||
setState(() => _sending = true);
|
||||
try {
|
||||
if (edit == null) {
|
||||
await widget.repository.submitInquiry(
|
||||
_title.text.trim(),
|
||||
_body.text.trim(),
|
||||
);
|
||||
} else {
|
||||
await widget.repository.updateInquiry(
|
||||
edit.id,
|
||||
_title.text.trim(),
|
||||
_body.text.trim(),
|
||||
);
|
||||
}
|
||||
} on Exception catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
_toast(
|
||||
context,
|
||||
edit == null
|
||||
? context.l10n.inquirySubmitFailed(_reason(error))
|
||||
: context.l10n.inquiryEditFailed(_reason(error)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
_toast(
|
||||
context,
|
||||
edit == null
|
||||
? context.l10n.inquirySubmitted
|
||||
: context.l10n.inquiryEdited,
|
||||
);
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// 차단 설정 — 명세 `554:24090` · Figma `357:223694` *차단한 프로필이 표시됩니다.*
|
||||
///
|
||||
/// 화면 대조 원장 F6 의 21건에는 별도 행이 없으나, 설정의 「차단 설정」이
|
||||
/// 가리키는 화면이라 함께 만든다. 링크만 두고 화면이 없으면 막다른 길이 된다.
|
||||
///
|
||||
/// 목록과 해제만 있는 것이 Figma 그대로다. 차단을 **추가**하는 입구는 게시물·
|
||||
/// 댓글·프로필의 「신고/차단 바텀시트」(`527:18638`)이며 저장소의
|
||||
/// [MyRepository.block] 이 그 자리에서 불린다.
|
||||
class BlockedScreen extends StatefulWidget {
|
||||
const BlockedScreen({super.key, required this.repository});
|
||||
|
||||
final MyRepository repository;
|
||||
|
||||
@override
|
||||
State<BlockedScreen> createState() => _BlockedScreenState();
|
||||
}
|
||||
|
||||
class _BlockedScreenState extends State<BlockedScreen> {
|
||||
List<BlockedUser>? _blocked;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final blocked = await widget.repository.blockedUsers();
|
||||
if (mounted) setState(() => _blocked = blocked);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final blocked = _blocked;
|
||||
if (blocked == null) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
final t = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(context.l10n.settingsBlocked)),
|
||||
body: blocked.isEmpty
|
||||
? Center(child: Text(context.l10n.blockedEmpty, style: t.bodyMedium))
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
itemCount: blocked.length,
|
||||
itemBuilder: (context, i) {
|
||||
final user = blocked[i];
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: AppColors.cosmicSurfaceRaised,
|
||||
child: Text(user.nickname.characters.first),
|
||||
),
|
||||
title: Text(user.nickname),
|
||||
trailing: TextButton(
|
||||
onPressed: () => _unblock(user),
|
||||
child: Text(context.l10n.unblock),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _unblock(BlockedUser user) async {
|
||||
try {
|
||||
await widget.repository.unblock(user.uid);
|
||||
} on Exception catch (error) {
|
||||
if (!mounted) return;
|
||||
_toast(context, context.l10n.unblockFailed(_reason(error)));
|
||||
return;
|
||||
}
|
||||
await _load();
|
||||
}
|
||||
}
|
||||
|
||||
/// 실패 사유 한 줄. 저장소가 던지는 예외를 화면 문구로 바꾼다.
|
||||
///
|
||||
/// 내부 예외 문자열을 그대로 띄우지 않는 것은 경로·스택이 새기 때문이다
|
||||
/// (README 「목 모드에서의 로그인」과 같은 이유).
|
||||
String _reason(Object error) => switch (error) {
|
||||
PhotoRejected(:final message) => message,
|
||||
_ => appL10n.commonTryLater,
|
||||
};
|
||||
|
||||
void _toast(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../home/home_screen.dart' show formatPop;
|
||||
import 'my_data.dart';
|
||||
import 'widgets/my_cards.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 티어 정보 — 화면 대조 원장 F6 · Figma `373:633256`.
|
||||
///
|
||||
/// 명세 `549:22185` — *티어별 이미지 · 티어명 · 기준치 · **티어가 낮은 순으로 소팅***
|
||||
///
|
||||
/// C-18 — 기준치는 **레벨 구간**이다. 누적 기여 POP 문턱과 칭호별 권한·보상은 적지
|
||||
/// 않는다. Passport Tier(B-65)는 별개 체계라 따로 늘어놓는다.
|
||||
class TierInfoScreen extends StatelessWidget {
|
||||
const TierInfoScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 시안 `373:633256` 한 줄 — 새싹 80 · 간격 17 · 이름 20/500 · 기준 12/400 #84868e ·
|
||||
// 위아래 12 · 밑줄 #1b1c1c.
|
||||
Widget row(String label, String note, {bool sprout = true}) => Container(
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: Color(0xFF1B1C1C))),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
height: 80,
|
||||
child: sprout
|
||||
? Image.asset('assets/figma/icon-tier-sprout.png')
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 17),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: myText(20, FontWeight.w500)),
|
||||
if (note.isNotEmpty)
|
||||
Text(
|
||||
note,
|
||||
style: myText(
|
||||
12,
|
||||
FontWeight.w400,
|
||||
color: const Color(0xFF84868E),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(context.l10n.tierInfoTitle)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
|
||||
children: [
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: myText(18, FontWeight.w500),
|
||||
children: [
|
||||
TextSpan(text: context.l10n.tierPromptPrefix),
|
||||
TextSpan(
|
||||
text: context.l10n.tierPromptRank,
|
||||
style: TextStyle(color: AppColors.cosmicAccent),
|
||||
),
|
||||
TextSpan(text: context.l10n.tierPromptSuffix),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// 낮은 순으로. LevelTitle 선언 순서가 곧 낮은 순이다. 기준치는 레벨 구간(C-18).
|
||||
for (final title in LevelTitle.values)
|
||||
row('${title.label} TIER', title.rangeLabel),
|
||||
// Passport Tier 는 레벨과 별개 체계다(B-65 · FR-MY-01). 시안에 없는 구획이다.
|
||||
Text('PASSPORT', style: myText(16, FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n.tierPassportNote,
|
||||
style: myText(12, FontWeight.w400, color: const Color(0xFF84868E)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final tier in PassportTier.values)
|
||||
row(tier.label, '', sprout: false),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 티어 상세 — 화면 대조 원장 F6 · Figma `494:16177`.
|
||||
///
|
||||
/// 명세 `549:22206` — *갤럭시에서 선택된 기본 행성의 티어를 메인으로 상단에 위치 ·*
|
||||
/// *나머지 행성 티어는 티어 높은순으로 소팅*
|
||||
///
|
||||
/// 칭호는 개인 레벨에서만 나오므로(C-18) 행성별로는 **기여 실적(POP)만** 보인다.
|
||||
class TierDetailScreen extends StatelessWidget {
|
||||
const TierDetailScreen({super.key, required this.tiers});
|
||||
|
||||
final List<PlanetTier> tiers;
|
||||
|
||||
/// 기본 행성이 맨 앞, 나머지는 기여 POP 높은 순.
|
||||
List<PlanetTier> get ordered {
|
||||
final primary = tiers.where((p) => p.isPrimary).toList();
|
||||
final rest = tiers.where((p) => !p.isPrimary).toList()
|
||||
..sort((a, b) => b.contributedPop.compareTo(a.contributedPop));
|
||||
return [...primary, ...rest];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final list = ordered;
|
||||
final top = list.fold<int>(
|
||||
0,
|
||||
(m, p) => p.contributedPop > m ? p.contributedPop : m,
|
||||
);
|
||||
// 시안 `494:16177` — 기본 행성 카드(#19191c · 분홍 획) · 구분선 #2c2c2c · 나머지 카드.
|
||||
// 행성별 칭호는 두지 않는다(C-18) — 시안의 「GLOW TIER」 줄은 비우고 기여 실적만 그린다.
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('TIER')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
for (final (i, planet) in list.indexed) ...[
|
||||
if (i > 0) const SizedBox(height: 16),
|
||||
_PlanetCard(
|
||||
planet: planet,
|
||||
progress: top == 0 ? 0 : planet.contributedPop / top,
|
||||
),
|
||||
if (i == 0 && planet.isPrimary && list.length > 1) ...[
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(
|
||||
height: 1,
|
||||
child: ColoredBox(color: MyColors.divider),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 행성 한 장 — 행성 60 + 이름 12/600 · 진행 바 · 「누적 기여 / N POP」.
|
||||
class _PlanetCard extends StatelessWidget {
|
||||
const _PlanetCard({required this.planet, required this.progress});
|
||||
|
||||
final PlanetTier planet;
|
||||
final double progress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: planet.isPrimary
|
||||
? BoxDecoration(
|
||||
color: MyColors.card,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.cosmicAccent),
|
||||
)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 60,
|
||||
child: Column(
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/figma/my-planet-1.png',
|
||||
width: 60,
|
||||
height: 60,
|
||||
),
|
||||
Text(
|
||||
planet.fandomName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyProgressBar(value: progress),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.tierTotalContribution,
|
||||
style: myText(
|
||||
12,
|
||||
FontWeight.w500,
|
||||
color: AppColors.cosmicAccent,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${formatPop(planet.contributedPop)} POP',
|
||||
style: myText(
|
||||
12,
|
||||
FontWeight.w500,
|
||||
color: AppColors.cosmicAccent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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