Files
xianyan/lib/features/onboarding/presentation/pages/agreement_page.dart
Developer ae1df22732 feat: v6.10.3 多语言翻译补全 + 17项功能修复
- 引导页协议多语言支持(languageId传递)
- 登录页双书名号修复 + 注册页协议勾选
- 个人中心页面多语言(18个翻译键)
- 网络断开提示增加关闭/刷新按钮
- 了解我们:新增秋叶qy开发者 + ayk签名修改 + 贡献者精简 + 微风暴微信搜索
- iOS快捷按钮重复修复(删除Info.plist静态定义)
- 测试账号123456警告提示
- 扫码登录自动跳转(HTTP轮询+WebSocket双通道)
- 登录页老用户按钮改次要色
- Syncfusion图表崩溃修复(DeferredBuilder+animationDuration:0)
- macOS标题栏跟随软件夜间模式
- 平台兼容分发渠道弹窗
- 软件著作权图片+交叉水印
- 桌面小部件平台兼容说明默认收起
- iOS/macOS图标更新+名称确认为闲言
- 12个语言文件补全roleNative+7个分发渠道翻译字段
2026-06-02 04:50:32 +08:00

1041 lines
33 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 — 软件协议页引导页第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;
}