Files
xianyan/lib/features/settings/presentation/experimental_features_page.dart
2026-06-18 03:09:19 +08:00

1193 lines
37 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.
/// ============================================================
/// 闲言APP — Beta 功能页面
/// 创建时间: 2026-05-30
/// 更新时间: 2026-06-17
/// 作用: 展示开发中/测试中/预览中的功能列表和问题列表接入远程FeatureFlag服务
/// 上次更新: 修复问卷提交后按钮隐藏只在iOS端生效的问题——Sheet返回值即时更新UI + await修复时序
/// ============================================================
import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../core/storage/kv_storage.dart';
import '../../../core/utils/platform/platform_utils.dart' as pu;
import '../../../shared/widgets/feedback/app_toast.dart';
import '../../../core/services/feature/feature_flag_provider.dart';
import '../../../core/services/feature/feature_flag_service.dart';
import '../../../core/services/form/form_collect_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/theme/app_spacing.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/theme/app_radius.dart';
import '../../../l10n/translations.dart';
import '../../../shared/widgets/adaptive/keyboard_safe_sheet.dart';
import '../../../shared/widgets/containers/glass_container.dart';
class ExperimentalFeaturesPage extends ConsumerStatefulWidget {
const ExperimentalFeaturesPage({super.key});
@override
ConsumerState<ExperimentalFeaturesPage> createState() =>
_ExperimentalFeaturesPageState();
}
class _ExperimentalFeaturesPageState
extends ConsumerState<ExperimentalFeaturesPage> {
int _selectedTab = 0;
String _issueFilter = 'all';
late final PageController _pageController;
bool _questionnaireSubmitted = false;
@override
void initState() {
super.initState();
// 鸿蒙端拦截跳转弹出toast并返回
if (pu.isOhos) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
final t = ref.read(translationsProvider);
AppToast.showInfo(t.beta.comingSoon);
Navigator.pop(context);
}
});
return;
}
_pageController = PageController(initialPage: _selectedTab);
// 读取问卷提交状态
_loadQuestionnaireSubmitted();
}
/// 读取问卷是否已提交
Future<void> _loadQuestionnaireSubmitted() async {
final prefs = await SharedPreferences.getInstance();
final submitted = prefs.getBool('beta_questionnaire_submitted') ?? false;
if (mounted && submitted != _questionnaireSubmitted) {
setState(() => _questionnaireSubmitted = submitted);
}
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final ext = AppTheme.ext(context);
final t = ref.watch(translationsProvider);
return CupertinoPageScaffold(
backgroundColor: ext.bgPrimary,
navigationBar: CupertinoNavigationBar(
middle: Text(
t.beta.pageTitle,
style: AppTypography.headline.copyWith(color: ext.textPrimary),
),
backgroundColor: ext.bgPrimary.withValues(alpha: 0.85),
border: null,
previousPageTitle: t.beta.back,
),
child: SafeArea(
child: Column(
children: [
const SizedBox(height: AppSpacing.sm),
_buildSegmentedControl(ext, t),
const SizedBox(height: AppSpacing.sm),
Expanded(
child: PageView(
controller: _pageController,
physics: const BouncingScrollPhysics(),
onPageChanged: (index) {
setState(() => _selectedTab = index);
},
children: [_buildFeaturesTab(ext, t), _buildIssuesTab(ext, t)],
),
),
// 底部问卷按钮(提交后隐藏)
if (!_questionnaireSubmitted)
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.md,
AppSpacing.sm,
AppSpacing.md,
AppSpacing.md,
),
child: SizedBox(
width: double.infinity,
child: CupertinoButton(
color: ext.accent,
borderRadius: BorderRadius.circular(10),
padding: const EdgeInsets.symmetric(vertical: 14),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
CupertinoIcons.question_circle_fill,
size: 18,
color: ext.textOnAccent,
),
const SizedBox(width: 6),
Text(
t.beta.questionnaireBtn,
style: AppTypography.subhead.copyWith(
color: ext.textOnAccent,
fontWeight: FontWeight.w600,
),
),
],
),
onPressed: () => _showQuestionnaire(ext, t),
),
),
),
],
),
),
);
}
// ---- 问卷 ----
/// 弹出问卷Sheet
///
/// Sheet 关闭后返回是否已提交:
/// - 优先使用返回值立即更新 UI避免 SharedPreferences 跨平台时序差异)
/// - 再异步读取 SharedPreferences 作为兜底,确保状态最终一致
Future<void> _showQuestionnaire(AppThemeExtension ext, T t) async {
final submitted = await showCupertinoModalPopup<bool>(
context: context,
builder: (_) => _QuestionnaireSheet(ext: ext, t: t),
);
if (!mounted) return;
// 立即根据 Sheet 返回值更新 UI所有平台一致生效
if (submitted == true && !_questionnaireSubmitted) {
setState(() => _questionnaireSubmitted = true);
}
// 异步刷新作为兜底,确保与持久化状态一致
_loadQuestionnaireSubmitted();
}
// ---- 分段控制器 ----
Widget _buildSegmentedControl(AppThemeExtension ext, T t) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: SizedBox(
width: double.infinity,
child: CupertinoSlidingSegmentedControl<int>(
groupValue: _selectedTab,
thumbColor: ext.bgElevated,
backgroundColor: ext.bgSecondary,
padding: const EdgeInsets.all(3),
onValueChanged: (value) {
if (value != null && value != _selectedTab) {
setState(() => _selectedTab = value);
_pageController.animateToPage(
value,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
},
children: {
0: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
child: Text(
t.beta.previewTab,
style: AppTypography.subhead.copyWith(
color: _selectedTab == 0
? ext.textPrimary
: ext.textSecondary,
fontWeight: _selectedTab == 0
? FontWeight.w600
: FontWeight.w400,
),
),
),
1: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
child: Text(
t.beta.issuesTab,
style: AppTypography.subhead.copyWith(
color: _selectedTab == 1
? ext.textPrimary
: ext.textSecondary,
fontWeight: _selectedTab == 1
? FontWeight.w600
: FontWeight.w400,
),
),
),
},
),
),
);
}
// ---- 功能 Tab ----
Widget _buildFeaturesTab(AppThemeExtension ext, T t) {
final flagsAsync = ref.watch(remoteFeatureFlagsProvider);
return flagsAsync.when(
loading: () => const Center(child: CupertinoActivityIndicator()),
error: (error, _) => _buildErrorState(ext, t, error),
data: (flags) {
if (flags.isEmpty) return _buildFlagsEmptyState(ext, t);
return _buildFlagsList(ext, t, flags);
},
);
}
Widget _buildFlagsList(
AppThemeExtension ext,
T t,
List<FeatureFlagItem> flags,
) {
// 仅显示服务端启用的功能标志
final visibleFlags = flags.where((f) => f.enabled).toList();
return CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
CupertinoSliverRefreshControl(
onRefresh: () async {
await ref.read(remoteFeatureFlagsProvider.notifier).refresh();
},
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.md,
0,
AppSpacing.md,
AppSpacing.xl,
),
sliver: SliverList.separated(
itemCount: visibleFlags.length,
separatorBuilder: (_, __) => const SizedBox(height: AppSpacing.sm),
itemBuilder: (context, index) {
return _RemoteFeatureCard(
flag: visibleFlags[index],
ext: ext,
t: t,
onToggle: (key, enabled) {
_showToggleConfirmDialog(
ext,
t,
visibleFlags[index],
enabled,
);
},
);
},
),
),
],
);
}
void _showToggleConfirmDialog(
AppThemeExtension ext,
T t,
FeatureFlagItem flag,
bool currentEnabled,
) {
showCupertinoDialog<void>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
title: Text(flag.name),
content: Text(
currentEnabled
? t.beta.confirmClose.replaceAll('{0}', flag.name)
: t.beta.confirmOpen.replaceAll('{0}', flag.name),
),
actions: [
CupertinoDialogAction(
isDestructiveAction: true,
child: Text(t.beta.cancel),
onPressed: () => Navigator.of(ctx).pop(),
),
CupertinoDialogAction(
isDefaultAction: true,
child: Text(currentEnabled ? t.beta.close : t.beta.open),
onPressed: () {
Navigator.of(ctx).pop();
RemoteFeatureFlagService.instance
.getFlag(flag.key)
?.copyWith(enabled: !currentEnabled);
ref.invalidate(remoteFeatureFlagsProvider);
},
),
],
),
);
}
Widget _buildFlagsEmptyState(AppThemeExtension ext, T t) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('🔬', style: TextStyle(fontSize: 48)),
const SizedBox(height: AppSpacing.sm),
Text(
t.beta.emptyFeatures,
style: AppTypography.subhead.copyWith(color: ext.textSecondary),
),
const SizedBox(height: AppSpacing.xs),
CupertinoButton(
onPressed: () {
ref.read(remoteFeatureFlagsProvider.notifier).refresh();
},
child: Text(
t.beta.reload,
style: AppTypography.subhead.copyWith(color: ext.accent),
),
),
],
),
);
}
Widget _buildErrorState(AppThemeExtension ext, T t, Object error) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('😵', style: TextStyle(fontSize: 48)),
const SizedBox(height: AppSpacing.sm),
Text(
t.beta.loadFailed,
style: AppTypography.subhead.copyWith(color: ext.textSecondary),
),
const SizedBox(height: AppSpacing.xs),
Text(
error.toString(),
style: AppTypography.caption1.copyWith(color: ext.textHint),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpacing.sm),
CupertinoButton(
onPressed: () {
ref.read(remoteFeatureFlagsProvider.notifier).refresh();
},
child: Text(
t.beta.retry,
style: AppTypography.subhead.copyWith(color: ext.accent),
),
),
],
),
);
}
// ---- 问题列表 Tab从远程API获取 ----
Widget _buildIssuesTab(AppThemeExtension ext, T t) {
final flagsAsync = ref.watch(remoteFeatureFlagsProvider);
return flagsAsync.when(
loading: () => const Center(child: CupertinoActivityIndicator()),
error: (_, __) => _buildIssuesFromService(ext, t),
data: (_) => _buildIssuesFromService(ext, t),
);
}
/// 从 RemoteFeatureFlagService 获取所有问题(全局+功能关联)
Widget _buildIssuesFromService(AppThemeExtension ext, T t) {
final allIssues = RemoteFeatureFlagService.instance.allIssues;
final filteredIssues = _issueFilter == 'all'
? allIssues
: allIssues.where((i) => i.status.id == _issueFilter).toList();
return Column(
children: [
_buildFilterChips(ext, t),
const SizedBox(height: AppSpacing.sm),
Expanded(
child: filteredIssues.isEmpty
? _buildIssuesEmptyState(ext, t)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(
AppSpacing.md,
0,
AppSpacing.md,
AppSpacing.xl,
),
physics: const BouncingScrollPhysics(),
itemCount: filteredIssues.length,
separatorBuilder: (_, __) =>
const SizedBox(height: AppSpacing.sm),
itemBuilder: (context, index) {
return _IssueCard(
issue: filteredIssues[index],
ext: ext,
t: t,
);
},
),
),
],
);
}
Widget _buildFilterChips(AppThemeExtension ext, T t) {
final filters = [
('all', t.beta.filterAll),
('pending', t.beta.filterPending),
('fixing', t.beta.filterFixing),
('fixed', t.beta.filterFixed),
];
return SizedBox(
height: 36,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
itemCount: filters.length,
separatorBuilder: (_, __) => const SizedBox(width: AppSpacing.xs),
itemBuilder: (context, index) {
final (value, label) = filters[index];
final isSelected = _issueFilter == value;
return GestureDetector(
onTap: () => setState(() => _issueFilter = value),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: isSelected
? ext.accent.withValues(alpha: 0.15)
: ext.bgSecondary,
borderRadius: AppRadius.pillBorder,
border: isSelected
? Border.all(color: ext.accent.withValues(alpha: 0.4))
: null,
),
child: Center(
child: Text(
label,
style: AppTypography.caption1.copyWith(
color: isSelected ? ext.accent : ext.textSecondary,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
),
),
),
),
);
},
),
);
}
Widget _buildIssuesEmptyState(AppThemeExtension ext, T t) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('', style: TextStyle(fontSize: 48)),
const SizedBox(height: AppSpacing.sm),
Text(
t.beta.emptyIssues,
style: AppTypography.subhead.copyWith(color: ext.textSecondary),
),
],
),
);
}
}
// ---- 功能卡片 ----
class _RemoteFeatureCard extends StatelessWidget {
const _RemoteFeatureCard({
required this.flag,
required this.ext,
required this.t,
required this.onToggle,
});
final FeatureFlagItem flag;
final AppThemeExtension ext;
final T t;
final void Function(String key, bool enabled) onToggle;
@override
Widget build(BuildContext context) {
return GestureDetector(
onLongPress: () => onToggle(flag.key, flag.enabled),
child: GlassContainer(
depth: GlassDepth.elevated,
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_buildEmojiContainer(),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
flag.name,
style: AppTypography.body.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
),
),
_buildStatusTag(),
],
),
const SizedBox(height: 2),
Text(
flag.description,
style: AppTypography.caption1.copyWith(
color: ext.textSecondary,
),
),
],
),
),
],
),
const SizedBox(height: AppSpacing.sm),
_buildProgressBar(),
if (flag.rolloutPercentage < 1.0) ...[
const SizedBox(height: AppSpacing.xs),
_buildRolloutInfo(),
],
// 显示该功能关联的问题数量
if (flag.issues.isNotEmpty) ...[
const SizedBox(height: AppSpacing.xs),
_buildIssueCount(),
],
],
),
),
);
}
Widget _buildEmojiContainer() {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.12),
borderRadius: AppRadius.mdBorder,
),
alignment: Alignment.center,
child: Text(
flag.emoji ?? flag.status.emoji,
style: const TextStyle(fontSize: 20),
),
);
}
Widget _buildStatusTag() {
final color = switch (flag.status) {
FeatureFlagStatus.developing => ext.infoColor,
FeatureFlagStatus.testing => ext.warningColor,
FeatureFlagStatus.preview => ext.successColor,
FeatureFlagStatus.released => ext.accent,
};
// 优先使用后端动态配置的statusText否则使用翻译键
final label = flag.statusText ?? _getStatusTranslation(flag.status);
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: 2,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: AppRadius.pillBorder,
),
child: Text(
label,
style: AppTypography.caption2.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
);
}
/// 根据状态枚举获取翻译文本
String _getStatusTranslation(FeatureFlagStatus status) {
return switch (status) {
FeatureFlagStatus.developing => t.beta.statusDeveloping,
FeatureFlagStatus.testing => t.beta.statusTesting,
FeatureFlagStatus.preview => t.beta.statusPreview,
FeatureFlagStatus.released => t.beta.statusReleased,
};
}
Widget _buildProgressBar() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: AppRadius.pillBorder,
child: Stack(
children: [
Container(
height: 6,
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: AppRadius.pillBorder,
),
),
FractionallySizedBox(
widthFactor: flag.progress.clamp(0.0, 1.0),
child: Container(
height: 6,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [ext.accent, ext.accentLight],
),
borderRadius: AppRadius.pillBorder,
),
),
),
],
),
),
),
const SizedBox(width: AppSpacing.sm),
Text(
'${(flag.progress * 100).toInt()}%',
style: AppTypography.caption2.copyWith(
color: ext.textHint,
fontWeight: FontWeight.w600,
),
),
],
),
],
);
}
Widget _buildRolloutInfo() {
return Row(
children: [
Icon(CupertinoIcons.group_solid, size: 12, color: ext.textHint),
const SizedBox(width: 4),
Text(
t.beta.rolloutPercentage.replaceAll(
'{0}',
'${(flag.rolloutPercentage * 100).toInt()}',
),
style: AppTypography.caption2.copyWith(color: ext.textHint),
),
if (flag.targetGroup != null) ...[
const SizedBox(width: AppSpacing.sm),
Icon(CupertinoIcons.lab_flask_solid, size: 12, color: ext.textHint),
const SizedBox(width: 4),
Text(
t.beta.targetGroup.replaceAll('{0}', flag.targetGroup!),
style: AppTypography.caption2.copyWith(color: ext.textHint),
),
],
],
);
}
/// 显示关联问题数量
Widget _buildIssueCount() {
final pendingCount = flag.issues
.where((i) => i.status == IssueStatus.pending)
.length;
final fixingCount = flag.issues
.where((i) => i.status == IssueStatus.fixing)
.length;
final fixedCount = flag.issues
.where((i) => i.status == IssueStatus.fixed)
.length;
return Row(
children: [
Icon(CupertinoIcons.ant_fill, size: 12, color: ext.textHint),
const SizedBox(width: 4),
Text(
t.beta.issueStats
.replaceAll('{0}', '$pendingCount')
.replaceAll('{1}', '$fixingCount')
.replaceAll('{2}', '$fixedCount'),
style: AppTypography.caption2.copyWith(color: ext.textHint),
),
],
);
}
}
// ---- 问题卡片(使用远程模型 FeatureIssueItem ----
class _IssueCard extends StatelessWidget {
const _IssueCard({required this.issue, required this.ext, required this.t});
final FeatureIssueItem issue;
final AppThemeExtension ext;
final T t;
@override
Widget build(BuildContext context) {
return GlassContainer(
padding: const EdgeInsets.all(AppSpacing.md),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSeverityDot(),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
issue.description,
style: AppTypography.subhead.copyWith(color: ext.textPrimary),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: AppSpacing.xs),
Row(
children: [
_buildSeverityTag(),
const SizedBox(width: AppSpacing.xs),
_buildStatusTag(),
if (issue.flagKey != null) ...[
const SizedBox(width: AppSpacing.xs),
_buildFlagKeyTag(),
],
],
),
],
),
),
],
),
);
}
Widget _buildSeverityDot() {
final color = switch (issue.severity) {
IssueSeverity.high => ext.errorColor,
IssueSeverity.medium => ext.warningColor,
IssueSeverity.low => ext.successColor,
};
return Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(top: 6),
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
);
}
Widget _buildSeverityTag() {
final (label, color) = switch (issue.severity) {
IssueSeverity.high => (t.beta.severityHigh, ext.errorColor),
IssueSeverity.medium => (t.beta.severityMedium, ext.warningColor),
IssueSeverity.low => (t.beta.severityLow, ext.successColor),
};
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: 1,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: AppRadius.pillBorder,
),
child: Text(
label,
style: AppTypography.caption2.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
);
}
Widget _buildStatusTag() {
final (label, color) = switch (issue.status) {
IssueStatus.pending => (t.beta.statusPending, ext.errorColor),
IssueStatus.fixing => (t.beta.statusFixing, ext.warningColor),
IssueStatus.fixed => (t.beta.statusFixed, ext.successColor),
};
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: 1,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: AppRadius.pillBorder,
),
child: Text(
label,
style: AppTypography.caption2.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
);
}
/// 显示关联功能标签
Widget _buildFlagKeyTag() {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: 1,
),
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.12),
borderRadius: AppRadius.pillBorder,
),
child: Text(
issue.flagKey!,
style: AppTypography.caption2.copyWith(
color: ext.accent,
fontWeight: FontWeight.w500,
),
),
);
}
}
// ---- Beta问卷Sheet ----
/// Beta问卷Sheet
class _QuestionnaireSheet extends StatefulWidget {
final AppThemeExtension ext;
final T t;
const _QuestionnaireSheet({required this.ext, required this.t});
@override
State<_QuestionnaireSheet> createState() => _QuestionnaireSheetState();
}
class _QuestionnaireSheetState extends State<_QuestionnaireSheet> {
static const _draftKey = 'beta_questionnaire_draft';
int _step = 0; // 0-3: 问题1-4, -1: 不符合, 4: 完成
final _emailController = TextEditingController();
bool _isSubmitting = false;
@override
void initState() {
super.initState();
_loadDraft();
}
// ============================================================
// 草稿持久化 — 问卷进度实时保存
// ============================================================
/// 加载草稿:恢复上次未完成的步骤和邮箱
void _loadDraft() {
final step = KvStorage.getInt('${_draftKey}_step');
final email = KvStorage.getString('${_draftKey}_email') ?? '';
if (step != null && step >= 0 && step <= 3) {
_step = step;
_emailController.text = email;
}
}
/// 保存草稿:当前步骤和邮箱写入 KvStorage
void _saveDraft() {
KvStorage.setInt('${_draftKey}_step', _step);
KvStorage.setString('${_draftKey}_email', _emailController.text);
}
/// 清除草稿:问卷完成或关闭时调用
void _clearDraft() {
KvStorage.remove('${_draftKey}_step');
KvStorage.remove('${_draftKey}_email');
}
@override
void dispose() {
_emailController.dispose();
super.dispose();
}
/// 保存问卷已提交标记
Future<void> _markQuestionnaireSubmitted() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('beta_questionnaire_submitted', true);
}
void _answer(bool yes) {
if (_step == 0) {
// 问题1: 了解Google Play
if (!yes) {
setState(() => _step = -1);
_clearDraft();
return;
}
setState(() => _step = 1);
_saveDraft();
} else if (_step == 1) {
// 问题2: 有GMS设备
if (!yes) {
setState(() => _step = -1);
_clearDraft();
return;
}
setState(() => _step = 2);
_saveDraft();
} else if (_step == 2) {
// 问题3: 愿意参与内测
if (!yes) {
setState(() => _step = -1);
_clearDraft();
return;
}
setState(() => _step = 3);
_saveDraft();
}
}
Future<void> _submitEmail() async {
final email = _emailController.text.trim();
if (email.isEmpty || !email.contains('@') || !email.contains('gmail')) {
AppToast.showWarning(widget.t.beta.qInvalidEmail);
return;
}
setState(() => _isSubmitting = true);
try {
final ok = await FormCollectService.instance.submit(
email: email,
source: FormCollectSource.betaQuestionnaire,
);
if (ok) {
// 提交成功,保存标记并清除草稿
await _markQuestionnaireSubmitted();
_clearDraft();
setState(() {
_step = 4;
_isSubmitting = false;
});
} else {
AppToast.showError(widget.t.beta.qSubmitFailed);
setState(() => _isSubmitting = false);
}
} catch (e) {
AppToast.showError(widget.t.beta.qSubmitFailed);
setState(() => _isSubmitting = false);
}
}
/// 获取当前步骤的问题文本
String _getQuestionText() {
return switch (_step) {
0 => widget.t.beta.q1KnowGooglePlay,
1 => widget.t.beta.q2HasGmsDevice,
2 => widget.t.beta.q3WillingToBeta,
3 => widget.t.beta.q4EnterGmail,
_ => '',
};
}
@override
Widget build(BuildContext context) {
final ext = widget.ext;
final t = widget.t;
return KeyboardSafeSheet(
backgroundColor: ext.bgCard,
topRadius: 14,
scrollable: false,
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 拖拽指示器
Container(
width: 36,
height: 5,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: ext.textHint.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(3),
),
),
if (_step >= 0 && _step <= 3) ...[
// 进度
Text(
'${_step + 1}/4',
style: AppTypography.caption1.copyWith(color: ext.textHint),
),
const SizedBox(height: 12),
// 问题
Text(
_getQuestionText(),
style: AppTypography.headline.copyWith(color: ext.textPrimary),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
if (_step < 3) ...[
// 是/否按钮
Row(
children: [
Expanded(
child: CupertinoButton(
color: ext.accent,
borderRadius: BorderRadius.circular(10),
child: Text(
t.beta.qYes,
style: TextStyle(
color: ext.textOnAccent,
fontWeight: FontWeight.w600,
),
),
onPressed: () => _answer(true),
),
),
const SizedBox(width: 12),
Expanded(
child: CupertinoButton(
color: ext.bgElevated,
borderRadius: BorderRadius.circular(10),
child: Text(
t.beta.qNo,
style: TextStyle(color: ext.textSecondary),
),
onPressed: () => _answer(false),
),
),
],
),
] else ...[
// 问题4: Gmail输入
CupertinoTextField(
controller: _emailController,
placeholder: t.beta.q4GmailHint,
keyboardType: TextInputType.emailAddress,
padding: const EdgeInsets.all(14),
onChanged: (_) => _saveDraft(),
decoration: BoxDecoration(
color: ext.bgElevated,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: ext.dividerOnCard),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: CupertinoButton(
color: ext.accent,
borderRadius: BorderRadius.circular(10),
child: _isSubmitting
? CupertinoActivityIndicator(color: ext.textOnAccent)
: Text(
t.beta.qSubmit,
style: TextStyle(
color: ext.textOnAccent,
fontWeight: FontWeight.w600,
),
),
onPressed: _isSubmitting ? null : _submitEmail,
),
),
],
] else if (_step == -1) ...[
// 不符合条件
Icon(CupertinoIcons.info_circle, size: 48, color: ext.textHint),
const SizedBox(height: 12),
Text(
t.beta.qEndThankYou,
style: AppTypography.headline.copyWith(color: ext.textPrimary),
),
const SizedBox(height: 8),
Text(
t.beta.qEndNotQualified,
style: AppTypography.subhead.copyWith(color: ext.textSecondary),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: CupertinoButton(
color: ext.bgElevated,
borderRadius: BorderRadius.circular(10),
child: Text(
t.beta.close,
style: TextStyle(color: ext.textSecondary),
),
onPressed: () async {
// 不符合条件关闭时也保存标记并清除草稿
// 使用 await 确保写入完成后再关闭,避免跨平台时序问题
final navigator = Navigator.of(context);
await _markQuestionnaireSubmitted();
_clearDraft();
if (mounted) navigator.pop(true);
},
),
),
] else ...[
// 完成
Icon(
CupertinoIcons.checkmark_circle_fill,
size: 48,
color: ext.successColor,
),
const SizedBox(height: 12),
Text(
t.beta.qSubmitSuccess,
style: AppTypography.headline.copyWith(color: ext.textPrimary),
),
const SizedBox(height: 8),
Text(
t.beta.qEndThanks,
style: AppTypography.subhead.copyWith(color: ext.textSecondary),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: CupertinoButton(
color: ext.accent,
borderRadius: BorderRadius.circular(10),
child: Text(
t.beta.gotIt,
style: TextStyle(
color: ext.textOnAccent,
fontWeight: FontWeight.w600,
),
),
onPressed: () => Navigator.pop(context, true),
),
),
],
],
),
);
}
}