HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent c4e9a9430e
commit 5de04ac281
44 changed files with 14898 additions and 16 deletions
@@ -0,0 +1,271 @@
import 'package:flutter/material.dart';
import '../../../core/data/backend.dart';
import '../../../core/monetization/store.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/app_icons.dart';
import '../home_data.dart';
import '../home_screen.dart' show formatPop;
import '../../../core/i18n/app_locale.dart';
/// 아이템 시트 — Figma 사본 「아이템 구입」(헤더 지갑 · `252:160233`) ·
/// 「아이템 선택」(ZAP 옵션 「아이템」 · `343:204868`).
///
/// 두 시트는 제목과 카드 아랫줄만 다르다 — 구입은 「보유 : N」 + 가격, 선택은 「보유 : N개」.
/// 구입은 스토어 결제 → `fn-iap-verify` 검증 → 구매자 기여 POP · 팬덤 Drop(원장 B-84)이고
/// 결과는 앱 전역 알림으로 온다([Store.messages]). 사본의 결제 시트(「앱 내 구입」)는 OS 결제
/// 창이라 앱이 그리지 않는다. 구매가 곧 발동이라 보유 재고가 없어 「선택」(사용)은 준비 중이다.
Future<void> showItemSheet(
BuildContext context, {
required List<ItemProduct> items,
required bool purchase,
String fandomId = '',
}) => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
// 사본처럼 하단 탭 위를 덮는다.
useRootNavigator: true,
backgroundColor: Colors.transparent,
barrierColor: const Color(0xB3000000),
builder: (_) =>
_ItemSheet(items: items, purchase: purchase, fandomId: fandomId),
);
class _ItemSheet extends StatelessWidget {
const _ItemSheet({
required this.items,
required this.purchase,
required this.fandomId,
});
final List<ItemProduct> items;
final bool purchase;
final String fandomId;
void _say(BuildContext context, String message) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
Future<void> _tap(BuildContext context, ItemProduct item) async {
if (purchase && fandomId.isEmpty) {
// 결제 창을 열기 전에 막는다 — 서버도 소속 행성 없는 구매를 거부한다(E-PAY-01).
_say(context, context.l10n.itemJoinFirst);
return;
}
final store = Store.instance;
if (!purchase || store == null || item.productId.isEmpty) {
_say(context, purchase
? context.l10n.itemBuyNotReady
: context.l10n.itemUseNotReady);
return;
}
try {
await store.buy(item.productId);
} on BackendException catch (error) {
if (context.mounted) _say(context, error.message);
} on Exception {
if (context.mounted) _say(context, context.l10n.payOpenFailed);
}
}
@override
Widget build(BuildContext context) => Container(
// 사본 `Frame 1984081232` — #101010 · 위 r24 · 패딩 20/0 · 홈 인디케이터 자리까지 같은 색.
decoration: const BoxDecoration(
color: AppColors.cosmicSurface,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
padding: EdgeInsets.fromLTRB(
AppSpace.s4,
20,
AppSpace.s4,
20 + MediaQuery.paddingOf(context).bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Row(
children: [
Expanded(
child: Text(
purchase ? context.l10n.itemBuy : context.l10n.itemSelect,
style: const TextStyle(
fontFamily: AppFonts.display,
fontSize: 16,
fontWeight: FontWeight.w400,
height: 1.4,
color: AppColors.onCosmic,
),
),
),
// 사본 X 는 12 글리프 — 행 높이(22)를 늘리지 않도록 IconButton 대신 쓴다.
Semantics(
button: true,
label: context.l10n.commonClose,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Navigator.of(context).pop(),
child: const ImageIcon(
AppIcons.x,
size: 20,
color: AppColors.onCosmic,
),
),
),
],
),
),
const SizedBox(height: AppSpace.s5),
if (items.isEmpty)
SizedBox(
height: 91,
child: Center(
child: Text(
context.l10n.commonComingSoon,
style: TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.onCosmicMuted,
),
),
),
),
for (final (i, item) in items.indexed) ...[
if (i > 0) const SizedBox(height: AppSpace.s4),
_ItemCard(
item: item,
purchase: purchase,
onTap: () => _tap(context, item),
),
],
],
),
);
}
/// 사본 `Button` — 343×91 · #1A1A1C · 1 테두리 #583118 · r8 · 패딩 8/12 · 간격 12.
class _ItemCard extends StatelessWidget {
const _ItemCard({
required this.item,
required this.purchase,
required this.onTap,
});
final ItemProduct item;
final bool purchase;
final VoidCallback onTap;
static const _chip = TextStyle(
fontFamily: AppFonts.body,
fontSize: 10,
fontWeight: FontWeight.w600,
height: 1,
);
Widget _tag(String text, Color fill, Color ink) => Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
decoration: BoxDecoration(
color: fill,
borderRadius: BorderRadius.circular(2),
),
child: Text(text, style: _chip.copyWith(color: ink)),
);
@override
Widget build(BuildContext context) => Material(
color: AppColors.cosmicSurfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
side: const BorderSide(color: Color(0xFF583118)),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
item.name,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.4,
color: AppColors.onCosmic,
),
),
const SizedBox(width: 8),
_tag(
context.l10n.itemSendPop(formatPop(item.dropPop)),
const Color(0xFF512F24),
AppColors.cosmicOrange,
),
const SizedBox(width: 6),
_tag(
context.l10n.itemBuyerPop(formatPop(item.buyerPop)),
const Color(0xFF204037),
const Color(0xFF43AD86),
),
],
),
const SizedBox(height: 6),
Text(
item.description,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.4,
color: AppColors.onCosmicMuted,
),
),
const SizedBox(height: 12),
Row(
children: [
Text(
purchase
? context.l10n.itemOwned('${item.owned}')
: context.l10n.itemOwnedCount('${item.owned}'),
style: TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: purchase ? FontWeight.w500 : FontWeight.w600,
height: 1.4,
color: purchase
? const Color(0xFFDDDDDD)
: AppColors.onCosmic,
),
),
const Spacer(),
if (purchase)
Text(
'₩${formatPop(item.priceWon)}',
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.4,
color: AppColors.onCosmic,
),
),
],
),
],
),
),
),
);
}