1193 lines
37 KiB
Dart
1193 lines
37 KiB
Dart
/// ============================================================
|
||
/// 闲言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),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|