- 引导页协议多语言支持(languageId传递) - 登录页双书名号修复 + 注册页协议勾选 - 个人中心页面多语言(18个翻译键) - 网络断开提示增加关闭/刷新按钮 - 了解我们:新增秋叶qy开发者 + ayk签名修改 + 贡献者精简 + 微风暴微信搜索 - iOS快捷按钮重复修复(删除Info.plist静态定义) - 测试账号123456警告提示 - 扫码登录自动跳转(HTTP轮询+WebSocket双通道) - 登录页老用户按钮改次要色 - Syncfusion图表崩溃修复(DeferredBuilder+animationDuration:0) - macOS标题栏跟随软件夜间模式 - 平台兼容分发渠道弹窗 - 软件著作权图片+交叉水印 - 桌面小部件平台兼容说明默认收起 - iOS/macOS图标更新+名称确认为闲言 - 12个语言文件补全roleNative+7个分发渠道翻译字段
1041 lines
33 KiB
Dart
1041 lines
33 KiB
Dart
// ============================================================
|
||
// 闲言APP — 软件协议页(引导页第2页)
|
||
// 创建时间: 2026-05-21
|
||
// 更新时间: 2026-06-02
|
||
// 作用: 隐私政策/用户协议/权限说明,勾选同意后继续
|
||
// 上次更新: 协议内容/标题/更新日期支持多语言,章节解析兼容英文罗马数字编号
|
||
// ============================================================
|
||
|
||
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter/gestures.dart';
|
||
import 'package:flutter/material.dart' show Colors;
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import '../../../../core/constants/character_expression.dart';
|
||
import '../../../../l10n/translations.dart';
|
||
import '../../../../core/router/app_nav_extension.dart';
|
||
import '../../../../core/router/app_routes.dart';
|
||
import '../../../../l10n/app_locale.dart';
|
||
import '../../../../core/services/auth/permission_service.dart';
|
||
import '../../../../core/services/device/haptic_service.dart';
|
||
import '../../../../core/theme/app_radius.dart';
|
||
import '../../../../core/theme/app_spacing.dart';
|
||
import '../../../../core/theme/app_theme.dart';
|
||
import '../../../../core/theme/app_typography.dart';
|
||
import '../../../../shared/widgets/animation/appbar_character_sprite.dart';
|
||
import '../../../../shared/widgets/containers/glass_container.dart';
|
||
import '../../../agreements/data/agreement_data.dart';
|
||
import '../../../agreements/data/agreement_types.dart';
|
||
import '../../../../shared/widgets/media/watermarked_copyright_image.dart';
|
||
import '../../providers/onboarding_provider.dart';
|
||
import '../onboarding_page.dart';
|
||
import '../widgets/page_nav_header.dart';
|
||
|
||
class AgreementPage extends ConsumerStatefulWidget {
|
||
const AgreementPage({super.key});
|
||
|
||
@override
|
||
ConsumerState<AgreementPage> createState() => _AgreementPageState();
|
||
}
|
||
|
||
class _AgreementPageState extends ConsumerState<AgreementPage> {
|
||
late ScrollController _scrollController;
|
||
final Map<String, GlobalKey> _chapterKeys = {};
|
||
int _activeChapterIndex = 0;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_scrollController = ScrollController();
|
||
_scrollController.addListener(_onScroll);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_scrollController.removeListener(_onScroll);
|
||
_scrollController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _onScroll() {
|
||
final chapters = _chapterKeys.values.toList();
|
||
if (chapters.isEmpty || !_scrollController.hasClients) return;
|
||
|
||
final scrollOffset = _scrollController.offset + 80;
|
||
int closest = 0;
|
||
for (int i = 0; i < chapters.length; i++) {
|
||
final key = chapters[i];
|
||
final ctx = key.currentContext;
|
||
if (ctx == null) continue;
|
||
final box = ctx.findRenderObject() as RenderBox?;
|
||
if (box == null) continue;
|
||
final pos = box.localToGlobal(Offset.zero).dy;
|
||
if (pos <= scrollOffset) {
|
||
closest = i;
|
||
}
|
||
}
|
||
if (closest != _activeChapterIndex && mounted) {
|
||
setState(() => _activeChapterIndex = closest);
|
||
}
|
||
}
|
||
|
||
void _scrollToChapter(int index) {
|
||
final keys = _chapterKeys.values.toList();
|
||
if (index >= keys.length) return;
|
||
final ctx = keys[index].currentContext;
|
||
if (ctx == null) return;
|
||
HapticService.selection();
|
||
Scrollable.ensureVisible(
|
||
ctx,
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeInOut,
|
||
alignment: 0.1,
|
||
);
|
||
}
|
||
|
||
static List<_ChapterInfo> _parseChapters(String content, String languageId) {
|
||
final zhRegex = RegExp(r'^[零一二三四五六七八九十百]+[点]*[零一二三四五六七八九十]*、.+');
|
||
final enRegex = RegExp(r'^(Zero|[IVXLCDM]+)\.\s+.+');
|
||
final lines = content.split('\n');
|
||
final chapters = <_ChapterInfo>[];
|
||
for (final line in lines) {
|
||
final trimmed = line.trim();
|
||
if (trimmed.isEmpty) continue;
|
||
if (zhRegex.hasMatch(trimmed)) {
|
||
final title = trimmed
|
||
.replaceAll(RegExp(r'^[零一二三四五六七八九十百]+[点]*[零一二三四五六七八九十]*、'), '')
|
||
.trim();
|
||
if (title.isNotEmpty) {
|
||
chapters.add(_ChapterInfo(fullTitle: trimmed, shortTitle: title));
|
||
}
|
||
} else if (enRegex.hasMatch(trimmed)) {
|
||
final title = trimmed
|
||
.replaceFirst(RegExp(r'^(Zero|[IVXLCDM]+)\.\s+'), '')
|
||
.trim();
|
||
if (title.isNotEmpty) {
|
||
chapters.add(_ChapterInfo(fullTitle: trimmed, shortTitle: title));
|
||
}
|
||
}
|
||
}
|
||
return chapters;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final ext = AppTheme.ext(context);
|
||
final state = ref.watch(onboardingProvider);
|
||
final notifier = ref.read(onboardingProvider.notifier);
|
||
final ob = ref.watch(translationsProvider).onboarding;
|
||
final languageId = _localeToLanguageId(ref.watch(appLocaleProvider));
|
||
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final isLandscape = constraints.maxWidth > constraints.maxHeight;
|
||
|
||
return Center(
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 600),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||
child: Column(
|
||
children: [
|
||
SizedBox(height: isLandscape ? AppSpacing.xs : AppSpacing.sm),
|
||
PageNavHeader(
|
||
icon: CupertinoIcons.doc_text_fill,
|
||
previousLabel: ob.welcomeNavLabel,
|
||
onPrevious: () {
|
||
HapticService.light();
|
||
OnboardingNavScope.of(context).goToPage(0);
|
||
},
|
||
trailing: _buildSkipButton(
|
||
ext,
|
||
state,
|
||
notifier,
|
||
context,
|
||
ob,
|
||
),
|
||
),
|
||
SizedBox(height: isLandscape ? AppSpacing.xs : AppSpacing.sm),
|
||
_buildTitle(ext, ob),
|
||
const SizedBox(height: AppSpacing.xs),
|
||
_buildTabBar(ext, state, notifier, ob),
|
||
const SizedBox(height: AppSpacing.xs),
|
||
Expanded(child: _buildContent(ext, state, ob, languageId)),
|
||
const SizedBox(height: AppSpacing.xs),
|
||
_buildCheckboxes(ext, state, notifier, ob),
|
||
const SizedBox(height: AppSpacing.xs),
|
||
_buildContinueButton(ext, state, notifier, ob),
|
||
SizedBox(height: isLandscape ? AppSpacing.xs : AppSpacing.sm),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildTitle(AppThemeExtension ext, TOnboarding ob) {
|
||
return Row(
|
||
children: [
|
||
const AppBarCharacterSprite(
|
||
characterId: 'cat',
|
||
expression: CharacterExpression.think,
|
||
size: 36,
|
||
),
|
||
const SizedBox(width: AppSpacing.sm),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
ob.agreementTitle,
|
||
style: AppTypography.title1.copyWith(
|
||
color: ext.textPrimary,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
ob.agreementSubtitle,
|
||
style: AppTypography.caption1.copyWith(
|
||
color: ext.textSecondary,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: AppSpacing.sm),
|
||
Container(
|
||
width: 36,
|
||
height: 36,
|
||
decoration: BoxDecoration(
|
||
color: ext.accent.withValues(alpha: 0.12),
|
||
borderRadius: AppRadius.mdBorder,
|
||
),
|
||
child: Icon(
|
||
CupertinoIcons.doc_text_fill,
|
||
size: 18,
|
||
color: ext.accent,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildTabBar(
|
||
AppThemeExtension ext,
|
||
OnboardingState state,
|
||
OnboardingNotifier notifier,
|
||
TOnboarding ob,
|
||
) {
|
||
final tabLabels = <String>[
|
||
ob.privacyPolicyTab,
|
||
ob.userAgreementTab,
|
||
ob.permissionInfoTab,
|
||
];
|
||
return Row(
|
||
children: List.generate(tabLabels.length, (i) {
|
||
final isSelected = state.agreementTabIndex == i;
|
||
return Expanded(
|
||
child: GestureDetector(
|
||
onTap: () {
|
||
HapticService.selection();
|
||
notifier.setAgreementTabIndex(i);
|
||
_chapterKeys.clear();
|
||
_activeChapterIndex = 0;
|
||
},
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 200),
|
||
margin: EdgeInsets.only(
|
||
right: i < tabLabels.length - 1 ? AppSpacing.xs : 0,
|
||
),
|
||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
|
||
decoration: BoxDecoration(
|
||
color: isSelected
|
||
? ext.accent.withValues(alpha: 0.15)
|
||
: ext.bgSecondary,
|
||
borderRadius: AppRadius.mdBorder,
|
||
border: isSelected ? Border.all(color: ext.accent) : null,
|
||
),
|
||
child: Text(
|
||
tabLabels[i],
|
||
style: AppTypography.caption1.copyWith(
|
||
color: isSelected ? ext.accent : ext.textSecondary,
|
||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}),
|
||
);
|
||
}
|
||
|
||
Widget _buildContent(
|
||
AppThemeExtension ext,
|
||
OnboardingState state,
|
||
TOnboarding ob,
|
||
String languageId,
|
||
) {
|
||
if (state.agreementTabIndex == 2) {
|
||
return _buildPermissionList(ext, ob);
|
||
}
|
||
|
||
final agreementType = _agreementTypeForIndex(state.agreementTabIndex);
|
||
final content = AgreementData.getContent(agreementType, languageId: languageId);
|
||
final updateDate = AgreementData.getUpdateDate(agreementType, languageId: languageId);
|
||
final chapters = _parseChapters(content, languageId);
|
||
|
||
if (_chapterKeys.isEmpty && chapters.isNotEmpty) {
|
||
for (int i = 0; i < chapters.length; i++) {
|
||
_chapterKeys['ch_$i'] = GlobalKey();
|
||
}
|
||
}
|
||
|
||
return GlassContainer(
|
||
depth: GlassDepth.elevated,
|
||
borderRadius: AppRadius.lgBorder,
|
||
padding: const EdgeInsets.all(AppSpacing.md),
|
||
child: Column(
|
||
children: [
|
||
_buildChapterNav(ext, chapters),
|
||
const SizedBox(height: AppSpacing.sm),
|
||
Expanded(
|
||
child: SingleChildScrollView(
|
||
controller: _scrollController,
|
||
child: _buildAgreementBody(
|
||
ext,
|
||
agreementType,
|
||
content,
|
||
updateDate,
|
||
chapters,
|
||
ob,
|
||
languageId,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildChapterNav(AppThemeExtension ext, List<_ChapterInfo> chapters) {
|
||
if (chapters.isEmpty) return const SizedBox.shrink();
|
||
|
||
return SizedBox(
|
||
height: 32,
|
||
child: ListView.separated(
|
||
scrollDirection: Axis.horizontal,
|
||
itemCount: chapters.length,
|
||
separatorBuilder: (_, __) => const SizedBox(width: 6),
|
||
itemBuilder: (context, index) {
|
||
final isActive = index == _activeChapterIndex;
|
||
return GestureDetector(
|
||
onTap: () => _scrollToChapter(index),
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 200),
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: isActive
|
||
? ext.accent.withValues(alpha: 0.15)
|
||
: ext.bgSecondary,
|
||
borderRadius: AppRadius.smBorder,
|
||
border: isActive
|
||
? Border.all(color: ext.accent, width: 0.5)
|
||
: null,
|
||
),
|
||
alignment: Alignment.center,
|
||
child: Text(
|
||
chapters[index].shortTitle,
|
||
style: AppTypography.caption2.copyWith(
|
||
color: isActive ? ext.accent : ext.textSecondary,
|
||
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildAgreementBody(
|
||
AppThemeExtension ext,
|
||
AgreementType agreementType,
|
||
String content,
|
||
String updateDate,
|
||
List<_ChapterInfo> chapters,
|
||
TOnboarding ob,
|
||
String languageId,
|
||
) {
|
||
final lines = content.split('\n');
|
||
final chapterIndexMap = <String, int>{};
|
||
for (int i = 0; i < chapters.length; i++) {
|
||
chapterIndexMap[chapters[i].fullTitle] = i;
|
||
}
|
||
|
||
final zhChapterRegex = RegExp(r'^[零一二三四五六七八九十百]+[点]*[零一二三四五六七八九十]*、.+');
|
||
final enChapterRegex = RegExp(r'^(Zero|[IVXLCDM]+)\.\s+.+');
|
||
|
||
final widgets = <Widget>[
|
||
Row(
|
||
children: [
|
||
Icon(agreementType.icon, size: 18, color: ext.accent),
|
||
const SizedBox(width: AppSpacing.sm),
|
||
Text(
|
||
agreementType.titleFor(languageId),
|
||
style: AppTypography.headline.copyWith(color: ext.textPrimary),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: AppSpacing.xs),
|
||
Text(
|
||
'${ob.updateDateLabel}$updateDate',
|
||
style: AppTypography.caption1.copyWith(color: ext.textHint),
|
||
),
|
||
const SizedBox(height: AppSpacing.sm),
|
||
Container(height: 0.5, color: ext.textHint.withValues(alpha: 0.2)),
|
||
const SizedBox(height: AppSpacing.sm),
|
||
];
|
||
|
||
for (final line in lines) {
|
||
final trimmed = line.trim();
|
||
if (trimmed.isEmpty) continue;
|
||
|
||
final isZhChapter = zhChapterRegex.hasMatch(trimmed);
|
||
final isEnChapter = enChapterRegex.hasMatch(trimmed);
|
||
|
||
if (isZhChapter || isEnChapter) {
|
||
final chIdx = chapterIndexMap[trimmed];
|
||
final key = chIdx != null && _chapterKeys.containsKey('ch_$chIdx')
|
||
? _chapterKeys['ch_$chIdx']
|
||
: null;
|
||
|
||
widgets.add(
|
||
Container(
|
||
key: key,
|
||
margin: const EdgeInsets.only(
|
||
top: AppSpacing.md,
|
||
bottom: AppSpacing.xs,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 3,
|
||
height: 16,
|
||
decoration: BoxDecoration(
|
||
color: ext.accent,
|
||
borderRadius: BorderRadius.circular(1.5),
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Expanded(
|
||
child: Text(
|
||
trimmed,
|
||
style: AppTypography.subhead.copyWith(
|
||
color: ext.textPrimary,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
|
||
if (trimmed.contains('软件著作权') ||
|
||
trimmed.contains('Software Copyright')) {
|
||
widgets.add(
|
||
const Padding(
|
||
padding: EdgeInsets.only(top: AppSpacing.sm),
|
||
child: WatermarkedCopyrightImage(),
|
||
),
|
||
);
|
||
}
|
||
} else if (trimmed.startsWith('•') || trimmed.startsWith('-')) {
|
||
widgets.add(
|
||
Padding(
|
||
padding: const EdgeInsets.only(left: AppSpacing.sm, bottom: 2),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
' • ',
|
||
style: AppTypography.body.copyWith(
|
||
color: ext.textSecondary,
|
||
height: 1.6,
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Text(
|
||
trimmed.replaceFirst(RegExp(r'^[•\-\s]+'), ''),
|
||
style: AppTypography.body.copyWith(
|
||
color: ext.textSecondary,
|
||
height: 1.6,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
} else if (trimmed.startsWith('|')) {
|
||
widgets.add(
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||
child: Text(
|
||
trimmed,
|
||
style: AppTypography.caption1.copyWith(
|
||
color: ext.textSecondary,
|
||
fontFamily: 'Courier',
|
||
height: 1.4,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
} else if (trimmed.startsWith(RegExp(r'^\d+\.\d+'))) {
|
||
widgets.add(
|
||
Padding(
|
||
padding: const EdgeInsets.only(left: AppSpacing.sm, bottom: 2),
|
||
child: Text(
|
||
trimmed,
|
||
style: AppTypography.body.copyWith(
|
||
color: ext.textSecondary,
|
||
height: 1.6,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
} else {
|
||
final isBold = trimmed.startsWith('**') && trimmed.endsWith('**');
|
||
widgets.add(
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 4),
|
||
child: Text(
|
||
trimmed.replaceAll('**', ''),
|
||
style: AppTypography.body.copyWith(
|
||
color: isBold ? ext.textPrimary : ext.textSecondary,
|
||
fontWeight: isBold ? FontWeight.w600 : FontWeight.normal,
|
||
height: 1.6,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: widgets,
|
||
);
|
||
}
|
||
|
||
Widget _buildPermissionList(AppThemeExtension ext, TOnboarding ob) {
|
||
final permissions = AppPermission.values
|
||
.where((p) => p.isPlatformRelevant)
|
||
.toList();
|
||
|
||
final groupedPermissions = <PermissionGroup, List<AppPermission>>{};
|
||
for (final group in PermissionGroup.values) {
|
||
groupedPermissions[group] = permissions
|
||
.where((p) => p.group == group)
|
||
.toList();
|
||
}
|
||
|
||
return GlassContainer(
|
||
depth: GlassDepth.elevated,
|
||
borderRadius: AppRadius.lgBorder,
|
||
padding: const EdgeInsets.all(AppSpacing.md),
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
CupertinoIcons.lock_shield_fill,
|
||
size: 18,
|
||
color: ext.accent,
|
||
),
|
||
const SizedBox(width: AppSpacing.sm),
|
||
Text(
|
||
ob.permissionUsageTitle,
|
||
style: AppTypography.headline.copyWith(
|
||
color: ext.textPrimary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: AppSpacing.xs),
|
||
Text(
|
||
ob.permissionUsageDesc,
|
||
style: AppTypography.caption1.copyWith(color: ext.textHint),
|
||
),
|
||
const SizedBox(height: AppSpacing.sm),
|
||
Container(height: 0.5, color: ext.textHint.withValues(alpha: 0.2)),
|
||
const SizedBox(height: AppSpacing.sm),
|
||
...groupedPermissions.entries.expand((entry) {
|
||
if (entry.value.isEmpty) return <Widget>[];
|
||
return [
|
||
_buildPermissionGroupHeader(ext, entry.key, ob),
|
||
const SizedBox(height: AppSpacing.sm),
|
||
...entry.value.map(
|
||
(perm) => _buildPermissionItem(ext, perm, ob),
|
||
),
|
||
const SizedBox(height: AppSpacing.sm),
|
||
];
|
||
}),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildPermissionGroupHeader(
|
||
AppThemeExtension ext,
|
||
PermissionGroup group,
|
||
TOnboarding ob,
|
||
) {
|
||
final groupColors = <PermissionGroup, Color>{
|
||
PermissionGroup.required: const Color(0xFFFF3B30),
|
||
PermissionGroup.optional: const Color(0xFF007AFF),
|
||
PermissionGroup.system: const Color(0xFF8E8E93),
|
||
};
|
||
final color = groupColors[group] ?? ext.accent;
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: AppSpacing.sm,
|
||
vertical: AppSpacing.xs,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.08),
|
||
borderRadius: AppRadius.mdBorder,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(group.icon, size: 14, color: color),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
group.label(context),
|
||
style: AppTypography.subhead.copyWith(
|
||
color: color,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(width: AppSpacing.xs),
|
||
if (group == PermissionGroup.required)
|
||
Text(
|
||
ob.requiredWarning,
|
||
style: AppTypography.caption2.copyWith(
|
||
color: color.withValues(alpha: 0.7),
|
||
),
|
||
)
|
||
else if (group == PermissionGroup.optional)
|
||
Text(
|
||
ob.optionalLabel,
|
||
style: AppTypography.caption2.copyWith(
|
||
color: color.withValues(alpha: 0.7),
|
||
),
|
||
)
|
||
else
|
||
Text(
|
||
ob.systemManagedLabel,
|
||
style: AppTypography.caption2.copyWith(
|
||
color: color.withValues(alpha: 0.7),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildPermissionItem(
|
||
AppThemeExtension ext,
|
||
AppPermission perm,
|
||
TOnboarding ob,
|
||
) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: AppSpacing.md),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
width: 32,
|
||
height: 32,
|
||
decoration: BoxDecoration(
|
||
color: perm.color.withValues(alpha: 0.12),
|
||
borderRadius: AppRadius.mdBorder,
|
||
),
|
||
child: Icon(perm.icon, size: 16, color: perm.color),
|
||
),
|
||
const SizedBox(width: AppSpacing.sm),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Text(
|
||
perm.label(context),
|
||
style: AppTypography.subhead.copyWith(
|
||
color: ext.textPrimary,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
if (perm.isRequired) ...[
|
||
const SizedBox(width: 4),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 4,
|
||
vertical: 1,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: const Color(
|
||
0xFFFF3B30,
|
||
).withValues(alpha: 0.12),
|
||
borderRadius: AppRadius.xsBorder,
|
||
),
|
||
child: Text(
|
||
ob.requiredBadge,
|
||
style: AppTypography.caption2.copyWith(
|
||
color: const Color(0xFFFF3B30),
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
if (perm.isVirtual) ...[
|
||
const SizedBox(width: 4),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 4,
|
||
vertical: 1,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: ext.accent.withValues(alpha: 0.12),
|
||
borderRadius: AppRadius.xsBorder,
|
||
),
|
||
child: Text(
|
||
ob.systemBadge,
|
||
style: AppTypography.caption2.copyWith(
|
||
color: ext.accent,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
perm.description(context),
|
||
style: AppTypography.caption1.copyWith(
|
||
color: ext.textSecondary,
|
||
height: 1.4,
|
||
),
|
||
maxLines: 3,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _onAgreementTap(String agreementName) {
|
||
HapticService.selection();
|
||
final privacyPolicies = {
|
||
'隐私政策',
|
||
'Privacy Policy',
|
||
'Datenschutzrichtlinie',
|
||
'Informativa privacy',
|
||
'Política de privacidad',
|
||
'Politique de confidentialité',
|
||
'سياسة الخصوصية',
|
||
'Политика конфиденциальности',
|
||
'गोपनीयता नीति',
|
||
'গোপনীয়তা নীতি',
|
||
'Política de privacidade',
|
||
'개인정보 처리방침',
|
||
'プライバシーポリシー',
|
||
'隱私政策',
|
||
};
|
||
final userAgreements = {
|
||
'用户服务协议',
|
||
'用户协议',
|
||
'User Service Agreement',
|
||
'User Agreement',
|
||
'Nutzungsbedingungen',
|
||
'Condizioni d\'uso',
|
||
'Acuerdo de usuario',
|
||
'Conditions d\'utilisation',
|
||
'اتفاقية المستخدم',
|
||
'Пользовательское соглашение',
|
||
'उपयोगकर्ता समझौता',
|
||
'ব্যবহারকারী চুক্তি',
|
||
'Acordo do usuário',
|
||
'이용약관',
|
||
'利用規約',
|
||
'使用者服務協議',
|
||
};
|
||
final permissionUsages = {
|
||
'软件权限使用说明',
|
||
'权限说明',
|
||
'Permission Usage',
|
||
'App Permission Usage',
|
||
'App-Berechtigungsnutzung',
|
||
'Utilizzo permessi app',
|
||
'Uso de permisos de la app',
|
||
'Utilisation des autorisations',
|
||
'شرح استخدام أذونات التطبيق',
|
||
'Использование разрешений приложения',
|
||
'ऐप अनुमति उपयोग',
|
||
'অ্যাপ অনুমতি ব্যবহার',
|
||
'Uso de permissões do app',
|
||
'앱 권한 사용 안내',
|
||
'アプリ権限の使用説明',
|
||
'軟體權限使用說明',
|
||
};
|
||
final tabIndex = privacyPolicies.contains(agreementName)
|
||
? 0
|
||
: userAgreements.contains(agreementName)
|
||
? 1
|
||
: permissionUsages.contains(agreementName)
|
||
? 2
|
||
: -1;
|
||
if (tabIndex >= 0) {
|
||
final notifier = ref.read(onboardingProvider.notifier);
|
||
notifier.setAgreementTabIndex(tabIndex);
|
||
_chapterKeys.clear();
|
||
_activeChapterIndex = 0;
|
||
}
|
||
}
|
||
|
||
Widget _buildCheckboxes(
|
||
AppThemeExtension ext,
|
||
OnboardingState state,
|
||
OnboardingNotifier notifier,
|
||
TOnboarding ob,
|
||
) {
|
||
return Column(
|
||
children: [
|
||
GestureDetector(
|
||
onTap: () {
|
||
HapticService.toggleSwitch();
|
||
notifier.togglePrivacy();
|
||
notifier.toggleTerms();
|
||
},
|
||
child: _buildCheckboxRow(
|
||
ext,
|
||
state.privacyAgreed && state.termsAgreed,
|
||
ob.agreeAllCheckbox,
|
||
onAgreementTap: _onAgreementTap,
|
||
),
|
||
),
|
||
const SizedBox(height: AppSpacing.sm),
|
||
GestureDetector(
|
||
onTap: () {
|
||
HapticService.toggleSwitch();
|
||
notifier.togglePermissionRead();
|
||
},
|
||
child: _buildCheckboxRow(
|
||
ext,
|
||
state.permissionRead,
|
||
ob.readPermissionCheckbox,
|
||
onAgreementTap: _onAgreementTap,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildCheckboxRow(
|
||
AppThemeExtension ext,
|
||
bool checked,
|
||
String text, {
|
||
void Function(String agreementName)? onAgreementTap,
|
||
}) {
|
||
final spans = <InlineSpan>[];
|
||
final regex = RegExp(r'《([^》]+)》');
|
||
int lastEnd = 0;
|
||
|
||
for (final match in regex.allMatches(text)) {
|
||
if (match.start > lastEnd) {
|
||
spans.add(
|
||
TextSpan(
|
||
text: text.substring(lastEnd, match.start),
|
||
style: AppTypography.caption1.copyWith(color: ext.textSecondary),
|
||
),
|
||
);
|
||
}
|
||
final agreementName = match.group(1)!;
|
||
final isClickable = onAgreementTap != null;
|
||
spans.add(
|
||
TextSpan(
|
||
text: '《$agreementName》',
|
||
style: AppTypography.caption1.copyWith(
|
||
color: isClickable ? ext.accent : ext.textSecondary,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
recognizer: isClickable
|
||
? (TapGestureRecognizer()
|
||
..onTap = () => onAgreementTap(agreementName))
|
||
: null,
|
||
),
|
||
);
|
||
lastEnd = match.end;
|
||
}
|
||
|
||
if (lastEnd < text.length) {
|
||
spans.add(
|
||
TextSpan(
|
||
text: text.substring(lastEnd),
|
||
style: AppTypography.caption1.copyWith(color: ext.textSecondary),
|
||
),
|
||
);
|
||
}
|
||
|
||
if (spans.isEmpty) {
|
||
spans.add(
|
||
TextSpan(
|
||
text: text,
|
||
style: AppTypography.caption1.copyWith(color: ext.textSecondary),
|
||
),
|
||
);
|
||
}
|
||
|
||
return Row(
|
||
children: [
|
||
Container(
|
||
width: 22,
|
||
height: 22,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: checked ? ext.accent : Colors.transparent,
|
||
border: checked ? null : Border.all(color: ext.textHint),
|
||
),
|
||
child: checked
|
||
? Icon(
|
||
CupertinoIcons.checkmark,
|
||
size: 12,
|
||
color: ext.textOnAccent,
|
||
)
|
||
: null,
|
||
),
|
||
const SizedBox(width: AppSpacing.sm),
|
||
Expanded(
|
||
child: RichText(text: TextSpan(children: spans)),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildContinueButton(
|
||
AppThemeExtension ext,
|
||
OnboardingState state,
|
||
OnboardingNotifier notifier,
|
||
TOnboarding ob,
|
||
) {
|
||
final enabled = state.canProceedAgreement;
|
||
return SizedBox(
|
||
width: double.infinity,
|
||
child: CupertinoButton.filled(
|
||
borderRadius: AppRadius.xlBorder,
|
||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.md),
|
||
onPressed: enabled
|
||
? () {
|
||
HapticService.light();
|
||
OnboardingNavScope.of(context).goToPage(2);
|
||
}
|
||
: null,
|
||
child: Text(
|
||
ob.agreeAndContinue,
|
||
style: AppTypography.headline.copyWith(
|
||
color: enabled ? ext.textOnAccent : ext.textDisabled,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
AgreementType _agreementTypeForIndex(int index) {
|
||
return switch (index) {
|
||
0 => AgreementType.privacyPolicy,
|
||
1 => AgreementType.userServiceAgreement,
|
||
2 => AgreementType.permissionUsage,
|
||
_ => AgreementType.privacyPolicy,
|
||
};
|
||
}
|
||
|
||
String _localeToLanguageId(Locale locale) {
|
||
if (locale.languageCode == 'zh') {
|
||
if (locale.countryCode == 'TW' || locale.scriptCode == 'Hant') {
|
||
return 'zh_tw';
|
||
}
|
||
return 'zh';
|
||
}
|
||
return locale.languageCode;
|
||
}
|
||
|
||
Widget _buildSkipButton(
|
||
AppThemeExtension ext,
|
||
OnboardingState state,
|
||
OnboardingNotifier notifier,
|
||
BuildContext context,
|
||
TOnboarding ob,
|
||
) {
|
||
final enabled = state.canProceedAgreement;
|
||
return GestureDetector(
|
||
onTap: enabled
|
||
? () async {
|
||
HapticService.light();
|
||
await notifier.completeOnboarding();
|
||
if (context.mounted) {
|
||
context.appGo(AppRoutes.home);
|
||
}
|
||
}
|
||
: null,
|
||
child: GlassContainer(
|
||
depth: GlassDepth.elevated,
|
||
borderRadius: AppRadius.fullBorder,
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: AppSpacing.sm,
|
||
vertical: AppSpacing.xs,
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
ob.skipOnboarding,
|
||
style: AppTypography.caption1.copyWith(
|
||
color: enabled ? ext.accent : ext.textDisabled,
|
||
),
|
||
),
|
||
const SizedBox(width: 2),
|
||
Icon(
|
||
CupertinoIcons.chevron_right,
|
||
size: 14,
|
||
color: enabled ? ext.accent : ext.textDisabled,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ChapterInfo {
|
||
const _ChapterInfo({required this.fullTitle, required this.shortTitle});
|
||
final String fullTitle;
|
||
final String shortTitle;
|
||
}
|