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 '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, }); final MyRepository repository; /// 「내 캠페인」이 읽는 저장소. null 이면 표본. final CampaignRepository? campaigns; /// 설정 > SNS 연동으로 그대로 내려간다. null 이면 설정 화면이 표본을 쓴다. final AccountLinker? linker; /// 설정 > 푸시 알림 설정으로 내려간다. 목 모드에서는 null 이고, 그때 설정 화면은 /// 권한 재요청 버튼을 그리지 않는다(요청할 플러그인이 없다). final PushMessaging? push; /// 설정 > 로그아웃 배선. null 이면 확인 모달만 띄운다. final AuthRepository? auth; @override State createState() => _MyScreenState(); } class _MyScreenState extends State { MyProfile? _profile; FocusStatus? _focus; bool _claiming = false; @override void initState() { super.initState(); _load(); } Future _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 _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 _changePhoto(MyProfile profile) async { final photo = await Navigator.of(context, rootNavigator: true) .push( 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 _push(Widget screen) => Navigator.of( context, rootNavigator: true, ).push(MaterialPageRoute(builder: (_) => screen)); /// 프로필 카드 오버레이 — 시안 `374:15738` · 명세 `549:22058`. 공유 4종이 붙는다. Future _showCard(MyProfile profile) => showDialog( 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 _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(), ], ), ), ); }