272 lines
8.8 KiB
Dart
272 lines
8.8 KiB
Dart
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|