Files
ZL-Q 87f92987b2 feat: 实现 iOS Apple Pay 内购支付功能
前端:
- 集成 in_app_purchase 插件,实现 IAP 支付流程
- 添加支付模块 (payments/) 处理产品获取、购买、验证
- 积分中心页面集成 Apple Pay 购买入口
- 设置页面重构: 关于/隐私/协议直接展示,删除 legal_center 子页面
- 修复欢迎引导页滚动检测阈值问题
- 修复解卦结果页 iOS 侧滑返回手势被阻止的问题
- 邀请码绑定按钮临时禁用(待后端实现)

后端:
- 新增 apple_iap_transactions 表记录交易
- 实现 Apple 服务器端验证 (App Store Server API)
- 支付成功后自动发放积分
- 支持 Sandbox/Production 环境切换
- 添加退款处理和交易状态机

协议:
- 更新积分流水协议,支持 purchase/refund 类型
- 新增 PAYMENT_* 错误码
2026-04-28 10:45:29 +08:00

875 lines
32 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import '../../../../core/auth/session_store.dart';
import '../../../../data/network/api_client.dart';
import '../../../divination/presentation/screens/divination_screen.dart';
import '../../../divination/presentation/screens/divination_result_screen.dart';
import '../../../divination/data/apis/divination_api.dart';
import '../../../divination/data/models/divination_params.dart';
import '../../../divination/data/models/divination_result.dart';
import '../../../notifications/data/repositories/notification_repository.dart';
import '../../../notifications/presentation/bloc/notification_bloc.dart';
import '../../../notifications/presentation/screens/notification_center_screen.dart';
import '../../../settings/data/apis/invite_api.dart';
import '../../../settings/data/models/profile_settings.dart';
import '../../../settings/data/repositories/invite_repository.dart';
import '../../../settings/presentation/screens/settings_screen.dart';
import '../../../../app/di/injection.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/app_color_palette.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/bottom_nav_bar.dart';
import '../../../../shared/widgets/divination/divination_summary_card.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({
super.key,
required this.account,
required this.userId,
required this.sessionStore,
required this.currentLocale,
required this.profileSettings,
required this.historyRecords,
required this.coinBalance,
required this.divinationApi,
required this.notificationBloc,
required this.notificationRepository,
required this.onLocaleChanged,
required this.onProfileSettingsChanged,
required this.onSaveProfile,
required this.onUploadAvatar,
required this.onDivinationCompleted,
required this.onDeleteHistorySession,
required this.onLogout,
required this.onDeleteAccount,
required this.onBalanceChanged,
});
final String account;
final String userId;
final SessionStore sessionStore;
final Locale currentLocale;
final ProfileSettingsV1 profileSettings;
final List<DivinationResultData> historyRecords;
final int coinBalance;
final DivinationApi divinationApi;
final NotificationBloc notificationBloc;
final NotificationRepository notificationRepository;
final Future<void> Function(String languageTag) onLocaleChanged;
final Future<void> Function(ProfileSettingsV1 settings)
onProfileSettingsChanged;
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
onSaveProfile;
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
final Future<void> Function(DivinationResultData result)
onDivinationCompleted;
final Future<void> Function(String threadId) onDeleteHistorySession;
final Future<void> Function() onLogout;
final Future<void> Function() onDeleteAccount;
final void Function(int newBalance) onBalanceChanged;
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
MainTab _currentTab = MainTab.home;
late final InviteRepository _inviteRepository;
late final ApiClient _apiClient;
@override
void initState() {
super.initState();
_apiClient = ApiClient(
baseUrl: appDependencies.backendUrl,
tokenProvider: widget.sessionStore.getToken,
);
final inviteApi = InviteApi(apiClient: _apiClient);
_inviteRepository = InviteRepositoryImpl(inviteApi: inviteApi);
WidgetsBinding.instance.addPostFrameCallback((_) {
_tryShowWelcomeDialog();
});
}
Future<void> _tryShowWelcomeDialog() async {
final hasRead = await widget.sessionStore.hasReadWelcome();
if (hasRead || !mounted) {
return;
}
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) {
return _WelcomeDialog(
onDone: () async {
await widget.sessionStore.setWelcomeRead(true);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final historyItems = widget.historyRecords;
return Scaffold(
backgroundColor: colors.surfaceContainerLow,
body: IndexedStack(
index: _currentTab == MainTab.home ? 0 : 1,
children: [
_HomeTab(
historyItems: historyItems,
sessionStore: widget.sessionStore,
userId: widget.account,
divinationApi: widget.divinationApi,
onDivinationCompleted: widget.onDivinationCompleted,
onDeleteHistorySession: widget.onDeleteHistorySession,
allowVibration: widget.profileSettings.notification.allowVibration,
notificationBloc: widget.notificationBloc,
notificationRepository: widget.notificationRepository,
profileSettings: widget.profileSettings,
onProfileSettingsChanged: widget.onProfileSettingsChanged,
),
_ProfileTab(
account: widget.account,
userId: widget.userId,
settings: widget.profileSettings,
coinBalance: widget.coinBalance,
inviteRepository: _inviteRepository,
apiClient: _apiClient,
onLocaleChanged: widget.onLocaleChanged,
onSettingsChanged: widget.onProfileSettingsChanged,
onSaveProfile: widget.onSaveProfile,
onUploadAvatar: widget.onUploadAvatar,
onLogout: widget.onLogout,
onDeleteAccount: widget.onDeleteAccount,
onBalanceChanged: widget.onBalanceChanged,
),
],
),
bottomNavigationBar: BottomNavBar(
currentTab: _currentTab,
onTabChange: (tab) {
setState(() => _currentTab = tab);
},
onLogoTap: _onStartDivination,
),
);
}
void _onStartDivination() {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DivinationScreen(
sessionStore: widget.sessionStore,
userId: widget.account,
onCompleted: widget.onDivinationCompleted,
allowVibration: widget.profileSettings.notification.allowVibration,
profileSettings: widget.profileSettings,
onProfileSettingsChanged: widget.onProfileSettingsChanged,
),
),
);
}
}
class _HomeTab extends StatelessWidget {
const _HomeTab({
required this.historyItems,
required this.sessionStore,
required this.userId,
required this.divinationApi,
required this.onDivinationCompleted,
required this.onDeleteHistorySession,
required this.allowVibration,
required this.notificationBloc,
required this.notificationRepository,
required this.profileSettings,
required this.onProfileSettingsChanged,
});
final List<DivinationResultData> historyItems;
final SessionStore sessionStore;
final String userId;
final DivinationApi divinationApi;
final Future<void> Function(DivinationResultData result)
onDivinationCompleted;
final Future<void> Function(String threadId) onDeleteHistorySession;
final bool allowVibration;
final NotificationBloc notificationBloc;
final NotificationRepository notificationRepository;
final ProfileSettingsV1 profileSettings;
final Future<void> Function(ProfileSettingsV1 settings)
onProfileSettingsChanged;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final palette = Theme.of(context).extension<AppColorPalette>()!;
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.only(
top: AppSpacing.lg,
bottom: AppSpacing.lg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.historyTitle,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(color: colors.primary),
),
IconButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => NotificationCenterScreen(
repository: notificationRepository,
onUnreadCountChanged: () {
return notificationBloc.handleEvent(
RefreshUnreadCount(),
);
},
),
),
);
},
icon: ListenableBuilder(
listenable: notificationBloc,
builder: (context, _) {
final count = notificationBloc.state.unreadCount;
if (count > 0) {
return Badge(
label: Text(count > 99 ? '99+' : '$count'),
child: Icon(
Icons.notifications,
color: colors.primary,
size: 28,
),
);
}
return Icon(
Icons.notifications,
color: colors.primary,
size: 28,
);
},
),
tooltip: l10n.notify,
),
],
),
),
const SizedBox(height: AppSpacing.xl),
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.xl),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.lg),
gradient: LinearGradient(
colors: [colors.primary, palette.accentPurple],
),
),
child: Column(
children: [
Icon(Icons.auto_awesome, color: colors.onPrimary, size: 48),
const SizedBox(height: AppSpacing.lg),
Text(
l10n.startJourney,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colors.onPrimary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: AppSpacing.sm),
Text(
l10n.journeySubtitle,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: colors.onPrimary),
),
const SizedBox(height: AppSpacing.lg),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: colors.surface,
foregroundColor: colors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DivinationScreen(
sessionStore: sessionStore,
userId: userId,
onCompleted: onDivinationCompleted,
allowVibration: allowVibration,
profileSettings: profileSettings,
onProfileSettingsChanged:
onProfileSettingsChanged,
),
),
);
},
child: Text(l10n.startNow),
),
],
),
),
),
const SizedBox(height: AppSpacing.xl),
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.historyTitle,
style: Theme.of(context).textTheme.titleMedium,
),
if (historyItems.length > 4)
TextButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DivinationHistoryScreen(
initialItems: historyItems,
divinationApi: divinationApi,
onDeleteHistorySession: onDeleteHistorySession,
),
),
);
},
child: Text(l10n.more),
),
],
),
),
const SizedBox(height: AppSpacing.md),
if (historyItems.isEmpty)
SizedBox(
width: double.infinity,
height: 200,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
l10n.noRecords,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.sm),
Text(l10n.noRecordsSubtitle),
],
),
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: historyItems.take(4).map((item) {
final threadId = item.threadId;
return Padding(
padding: const EdgeInsets.only(
left: AppSpacing.md,
right: AppSpacing.md,
bottom: AppSpacing.md,
),
child: threadId == null
? _HistoryCard(
item: item,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DivinationResultScreen(
data: item,
divinationApi: null,
enableIntroTransition: false,
),
),
);
},
)
: Dismissible(
key: ValueKey<String>('home-history-$threadId'),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
),
decoration: BoxDecoration(
color: colors.errorContainer,
borderRadius: BorderRadius.circular(
AppRadius.md,
),
),
child: Icon(
Icons.delete_outline,
color: colors.onErrorContainer,
),
),
confirmDismiss: (_) async => true,
onDismissed: (_) {
unawaited(onDeleteHistorySession(threadId));
},
child: _HistoryCard(
item: item,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DivinationResultScreen(
data: item,
divinationApi: divinationApi,
enableIntroTransition: false,
),
),
);
},
),
),
);
}).toList(),
),
],
),
),
);
}
}
class DivinationHistoryScreen extends StatefulWidget {
const DivinationHistoryScreen({
super.key,
required this.initialItems,
required this.divinationApi,
required this.onDeleteHistorySession,
});
final List<DivinationResultData> initialItems;
final DivinationApi divinationApi;
final Future<void> Function(String threadId) onDeleteHistorySession;
@override
State<DivinationHistoryScreen> createState() =>
_DivinationHistoryScreenState();
}
class _DivinationHistoryScreenState extends State<DivinationHistoryScreen> {
late List<DivinationResultData> _items;
@override
void initState() {
super.initState();
_items = List<DivinationResultData>.from(
widget.initialItems,
growable: true,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: Text(l10n.historyTitle)),
backgroundColor: colors.surfaceContainerLow,
body: _items.isEmpty
? Center(child: Text(l10n.noRecords))
: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.md),
itemCount: _items.length,
itemBuilder: (context, index) {
final item = _items[index];
final threadId = item.threadId;
return Padding(
padding: const EdgeInsets.only(
left: AppSpacing.md,
right: AppSpacing.md,
bottom: AppSpacing.md,
),
child: threadId == null
? _HistoryCard(
item: item,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DivinationResultScreen(
data: item,
divinationApi: null,
enableIntroTransition: false,
),
),
);
},
)
: Dismissible(
key: ValueKey<String>('history-$threadId'),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
),
decoration: BoxDecoration(
color: colors.errorContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Icon(
Icons.delete_outline,
color: colors.onErrorContainer,
),
),
confirmDismiss: (_) async => true,
onDismissed: (_) {
setState(() {
_items.removeAt(index);
});
unawaited(widget.onDeleteHistorySession(threadId));
},
child: _HistoryCard(
item: item,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DivinationResultScreen(
data: item,
divinationApi: widget.divinationApi,
enableIntroTransition: false,
),
),
);
},
),
),
);
},
),
);
}
}
class _ProfileTab extends StatelessWidget {
const _ProfileTab({
required this.account,
required this.userId,
required this.settings,
required this.coinBalance,
required this.inviteRepository,
required this.apiClient,
required this.onLocaleChanged,
required this.onSettingsChanged,
required this.onSaveProfile,
required this.onUploadAvatar,
required this.onLogout,
required this.onDeleteAccount,
required this.onBalanceChanged,
});
final String account;
final String userId;
final ProfileSettingsV1 settings;
final int coinBalance;
final InviteRepository inviteRepository;
final ApiClient apiClient;
final Future<void> Function(String languageTag) onLocaleChanged;
final Future<void> Function(ProfileSettingsV1 settings) onSettingsChanged;
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
onSaveProfile;
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
final Future<void> Function() onLogout;
final Future<void> Function() onDeleteAccount;
final void Function(int newBalance) onBalanceChanged;
@override
Widget build(BuildContext context) {
return SettingsScreen(
account: account,
userId: userId,
settings: settings,
coinBalance: coinBalance,
inviteRepository: inviteRepository,
apiClient: apiClient,
onInterfaceLanguageChanged: onLocaleChanged,
onSettingsChanged: onSettingsChanged,
onSaveProfile: onSaveProfile,
onUploadAvatar: onUploadAvatar,
onLogout: onLogout,
onDeleteAccount: onDeleteAccount,
onBalanceChanged: onBalanceChanged,
);
}
}
class _HistoryCard extends StatelessWidget {
const _HistoryCard({required this.item, required this.onTap});
final DivinationResultData item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final palette = Theme.of(context).extension<AppColorPalette>()!;
final categoryLabel = switch (item.params.questionType) {
QuestionType.career => l10n.questionTypeCareer,
QuestionType.love => l10n.questionTypeLove,
QuestionType.wealth => l10n.questionTypeWealth,
QuestionType.fortune => l10n.questionTypeFortune,
QuestionType.dream => l10n.questionTypeDream,
QuestionType.health => l10n.questionTypeHealth,
QuestionType.study => l10n.questionTypeStudy,
QuestionType.search => l10n.questionTypeSearch,
QuestionType.other => l10n.questionTypeOther,
};
final categoryStyle = switch (item.params.questionType) {
QuestionType.career || QuestionType.study => (
palette.categoryCareerBg,
palette.categoryCareerText,
),
QuestionType.love => (palette.categoryLoveBg, palette.categoryLoveText),
_ => (palette.categoryMoneyBg, palette.categoryMoneyText),
};
final normalizedSignType = item.signType.trim();
final isBestSign = normalizedSignType.contains('上上');
final isGoodSign = !isBestSign && normalizedSignType.contains('中上');
final isWorstSign = normalizedSignType.contains('下下');
final signLabel = isBestSign
? l10n.signTypeShangShang
: isGoodSign
? l10n.signTypeZhongShang
: isWorstSign
? l10n.signTypeXiaXia
: l10n.signTypeZhongXia;
final signStyle = isBestSign
? (palette.historyGoldBg, palette.historyGoldText)
: isGoodSign
? (colors.surfaceContainerHighest, colors.primary)
: isWorstSign
? (colors.errorContainer, colors.onErrorContainer)
: (palette.historyGrayBg, palette.historyGrayText);
return SizedBox(
width: double.infinity,
child: DivinationSummaryCard(
question: item.params.question,
onTap: onTap,
leading: Icon(
Icons.auto_awesome,
color: palette.historyBlueText,
size: 22,
),
leadingBackgroundColor: palette.historyBlueBg,
tags: [
DivinationSummaryTagData(
label: categoryLabel,
background: categoryStyle.$1,
foreground: categoryStyle.$2,
),
DivinationSummaryTagData(
label: item.guaName,
background: palette.historyBlueBg,
foreground: palette.historyBlueText,
),
DivinationSummaryTagData(
label: signLabel,
background: signStyle.$1,
foreground: signStyle.$2,
),
],
),
);
}
}
class _WelcomeDialog extends StatefulWidget {
const _WelcomeDialog({required this.onDone});
final Future<void> Function() onDone;
@override
State<_WelcomeDialog> createState() => _WelcomeDialogState();
}
class _WelcomeDialogState extends State<_WelcomeDialog> {
final ScrollController _scrollController = ScrollController();
bool _hasScrolledToBottom = false;
bool _hasCheckedInitialScroll = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_handleScroll);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_hasCheckedInitialScroll) {
_hasCheckedInitialScroll = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_syncScrollState();
});
}
}
@override
void dispose() {
_scrollController.removeListener(_handleScroll);
_scrollController.dispose();
super.dispose();
}
void _handleScroll() {
_syncScrollState();
}
void _syncScrollState() {
if (!_scrollController.hasClients) {
return;
}
final max = _scrollController.position.maxScrollExtent;
final current = _scrollController.offset;
final canReadAll = max <= 50.0 || current >= max - AppSpacing.md;
if (_hasScrolledToBottom == canReadAll) {
return;
}
setState(() {
_hasScrolledToBottom = canReadAll;
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final palette = Theme.of(context).extension<AppColorPalette>()!;
return Dialog(
insetPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.xl,
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 620),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.xl),
child: Column(
children: [
Text(
l10n.welcomeDialogTitle,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: AppSpacing.lg),
Expanded(
child: SingleChildScrollView(
controller: _scrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.welcomeParagraph1,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: AppSpacing.md),
Text(
l10n.welcomeParagraph2,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: AppSpacing.md),
Text(
l10n.welcomeParagraph3,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: AppSpacing.lg),
Text(
l10n.warningTitle,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(color: palette.warning),
),
const SizedBox(height: AppSpacing.xs),
Text(
l10n.warningBody,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: palette.warning,
),
),
],
),
),
),
const SizedBox(height: AppSpacing.md),
if (!_hasScrolledToBottom)
Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: Text(
l10n.scrollHint,
style: Theme.of(context).textTheme.bodySmall,
),
),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _hasScrolledToBottom
? () async {
await widget.onDone();
if (!context.mounted) {
return;
}
Navigator.of(context).pop();
}
: null,
style: FilledButton.styleFrom(
backgroundColor: _hasScrolledToBottom
? colors.primary
: colors.outline,
foregroundColor: colors.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.sm,
),
child: Text(
_hasScrolledToBottom
? l10n.understood
: l10n.readAllFirst,
),
),
),
),
],
),
),
),
);
}
}