Files
fanuzu/app/lib/features/my/my_screen.dart
T

584 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 '../galaxy/galaxy_data.dart';
import '../galaxy/galaxy_home_screen.dart' show GalaxyHomeScreen;
import '../inside/inside_data.dart' show InsideRepository;
import '../translation/translation_data.dart' show TranslationRepository;
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,
this.translation,
this.galaxy = const SampleGalaxyRepository(),
});
final MyRepository repository;
/// 「행성/성운」 칸 — GALAXY 「내 행성」과 같은 가입 목록(`users/{uid}/memberships`)을 읽는다.
final GalaxyRepository galaxy;
/// 「내 캠페인」이 읽는 저장소. null 이면 표본.
final CampaignRepository? campaigns;
/// 「내 인사이드」가 읽는 저장소. null 이면 표본.
final InsideRepository? insides;
/// 내 질문 상세의 번역 원천.
final TranslationRepository? translation;
/// 설정 > 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;
List<PlanetNode> _planets = const [];
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);
// 행성 목록 실패도 칸만 비운다.
final planets = await widget.galaxy
.load()
.then((s) => s.memberships.take(3).toList())
.catchError((_) => const <PlanetNode>[]);
if (mounted) {
setState(() {
_profile = profile;
_focus = focus;
_planets = planets;
});
}
}
/// 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: [
// 시안 세 칸 — 가입하거나 만든 행성/성운(최신 가입순). 그림은
// GALAXY 「내 행성」과 같은 기준(팬덤 stage)이고, 모자라면 빈 칸이다.
for (var i = 0; i < 3; i++) ...[
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: i < _planets.length
? const Color(0xFF2C2C2C)
: const Color(0x802C2C2C),
borderRadius: BorderRadius.circular(
AppRadius.md,
),
),
child: i < _planets.length
? Image.asset(
GalaxyHomeScreen.cardImageOf(
_planets[i],
i,
),
fit: BoxFit.contain,
semanticLabel: _planets[i].name,
)
: null,
),
),
),
],
],
),
],
),
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,
translation: widget.translation,
),
),
),
_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(),
],
),
),
);
}