550 lines
19 KiB
Dart
550 lines
19 KiB
Dart
import 'package:flutter/material.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 '../inside/inside_data.dart' show InsideRepository;
|
||
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 탭 루트 — 화면 대조 원장 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,
|
||
this.insides,
|
||
});
|
||
|
||
final MyRepository repository;
|
||
|
||
/// 「내 캠페인」이 읽는 저장소. null 이면 표본.
|
||
final CampaignRepository? campaigns;
|
||
|
||
/// 「내 인사이드」가 읽는 저장소. null 이면 표본.
|
||
final InsideRepository? insides;
|
||
|
||
/// 설정 > SNS 연동으로 그대로 내려간다. null 이면 설정 화면이 표본을 쓴다.
|
||
final AccountLinker? linker;
|
||
|
||
/// 설정 > 푸시 알림 설정으로 내려간다. 목 모드에서는 null 이고, 그때 설정 화면은
|
||
/// 권한 재요청 버튼을 그리지 않는다(요청할 플러그인이 없다).
|
||
final PushMessaging? push;
|
||
|
||
/// 설정 > 로그아웃 배선. null 이면 확인 모달만 띄운다.
|
||
final AuthRepository? auth;
|
||
|
||
@override
|
||
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(
|
||
MyListsScreen(
|
||
kind: MyListKind.inside,
|
||
insides: widget.insides,
|
||
),
|
||
),
|
||
),
|
||
_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) async {
|
||
final l10n = context.l10n;
|
||
final messenger = ScaffoldMessenger.of(this.context);
|
||
Navigator.of(context).pop();
|
||
try {
|
||
if (!await shareProfile(profile, target)) return;
|
||
messenger
|
||
..hideCurrentSnackBar()
|
||
..showSnackBar(
|
||
SnackBar(
|
||
content: Text(l10n.shareCopied),
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
} catch (_) {
|
||
messenger.showSnackBar(SnackBar(content: Text(l10n.commonRequestFailed)));
|
||
}
|
||
},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 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(),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|