feat(apps): 重构 UI 架构为 presentation 层并新增 l10n 国际化支持

This commit is contained in:
qzl
2026-03-27 14:05:03 +08:00
parent b1f0eb8921
commit c592cc7854
178 changed files with 10748 additions and 5764 deletions
+56
View File
@@ -0,0 +1,56 @@
import 'package:formz/formz.dart';
import '../../core/l10n/l10n.dart';
class Username extends FormzInput<String, String> {
const Username.pure() : super.pure('');
const Username.dirty([super.value = '']) : super.dirty();
@override
String? validator(String value) {
if (value.isEmpty) return L10n.current.inputUsernameRequired;
if (value.length < 3) return L10n.current.inputUsernameMin;
if (value.length > 30) return L10n.current.inputUsernameMax;
return null;
}
}
class Phone extends FormzInput<String, String> {
const Phone.pure() : super.pure('');
const Phone.dirty([super.value = '']) : super.dirty();
static final _regex = RegExp(r'^\d{7,14}$');
@override
String? validator(String value) {
final normalized = value.replaceAll(RegExp(r'\s+'), '');
if (normalized.isEmpty) return L10n.current.inputPhoneRequired;
if (!_regex.hasMatch(normalized)) return L10n.current.inputPhoneInvalid;
return null;
}
}
class Password extends FormzInput<String, String> {
const Password.pure() : super.pure('');
const Password.dirty([super.value = '']) : super.dirty();
@override
String? validator(String value) {
if (value.isEmpty) return L10n.current.inputPasswordRequired;
if (value.length < 6) return L10n.current.inputPasswordMin;
return null;
}
}
class VerificationCode extends FormzInput<String, String> {
const VerificationCode.pure() : super.pure('');
const VerificationCode.dirty([super.value = '']) : super.dirty();
@override
String? validator(String value) {
if (value.isEmpty) return L10n.current.inputCodeRequired;
if (!RegExp(r'^\d{6}$').hasMatch(value)) {
return L10n.current.inputCodeInvalid;
}
return null;
}
}
@@ -1,68 +0,0 @@
String formatPhoneForDisplay(String? rawPhone) {
final normalized = _normalizePhone(rawPhone);
if (normalized == null) {
return rawPhone?.trim() ?? '';
}
if (normalized.startsWith('+86') && normalized.length == 14) {
final local = normalized.substring(3);
return '${local.substring(0, 3)}****${local.substring(7)}';
}
if (!normalized.startsWith('+')) {
return normalized;
}
final digits = normalized.substring(1);
final countryCode = _detectCountryCode(digits);
if (countryCode == null) {
return normalized;
}
final localNumber = digits.substring(countryCode.length);
if (localNumber.length <= 4) {
return '+$countryCode $localNumber';
}
final tail = localNumber.substring(localNumber.length - 4);
return '+$countryCode ****$tail';
}
String? _normalizePhone(String? rawPhone) {
if (rawPhone == null) {
return null;
}
var phone = rawPhone.trim();
for (final separator in const [' ', '-', '(', ')']) {
phone = phone.replaceAll(separator, '');
}
if (phone.isEmpty) {
return null;
}
if (phone.startsWith('00') && phone.length > 2) {
phone = '+${phone.substring(2)}';
}
if (!phone.startsWith('+') && RegExp(r'^\d+$').hasMatch(phone)) {
phone = '+$phone';
}
return phone;
}
String? _detectCountryCode(String digits) {
const knownCodes = ['86', '1', '44', '81', '65', '33'];
for (final code in knownCodes) {
if (digits.startsWith(code) && digits.length > code.length + 3) {
return code;
}
}
for (int length = 3; length >= 1; length--) {
if (length >= digits.length) {
continue;
}
final candidate = digits.substring(0, length);
if (candidate.startsWith('0')) {
continue;
}
if (digits.length - length >= 4) {
return candidate;
}
}
return null;
}
@@ -1,35 +0,0 @@
const Map<String, String> _toolNameZhMap = {
'calendar.read': '读取日程',
'calendar.write': '写入日程',
'calendar.share': '共享日程',
'user.lookup': '查找联系人',
'memory.write': '写入记忆',
'memory.forget': '清理记忆',
};
const Map<String, String> _toolNameAliases = {
'calendar_read': 'calendar.read',
'calendar_write': 'calendar.write',
'calendar_share': 'calendar.share',
'user_lookup': 'user.lookup',
'memory_write': 'memory.write',
'memory_forget': 'memory.forget',
};
const List<String> automationToolOptions = [
'calendar.read',
'calendar.write',
'calendar.share',
'user.lookup',
'memory.write',
'memory.forget',
];
String localizeToolName(String rawName) {
final normalized = rawName.trim().toLowerCase();
if (normalized.isEmpty) {
return rawName;
}
final canonical = _toolNameAliases[normalized] ?? normalized;
return _toolNameZhMap[canonical] ?? rawName;
}
-41
View File
@@ -1,41 +0,0 @@
class Validators {
Validators._();
static String? phone(String? value) {
if (value == null || value.isEmpty) {
return '请输入手机号';
}
final phoneRegex = RegExp(r'^\+861[3-9]\d{9}$');
if (!phoneRegex.hasMatch(value)) {
return '请输入有效的 +86 手机号';
}
return null;
}
static String? password(String? value) {
if (value == null || value.isEmpty) {
return '请输入密码';
}
if (value.length < 8) {
return '密码至少需要8位';
}
return null;
}
static String? required(String? value, [String? fieldName]) {
if (value == null || value.isEmpty) {
return '请输入${fieldName ?? '内容'}';
}
return null;
}
static String? nickname(String? value) {
if (value == null || value.isEmpty) {
return '请输入昵称';
}
if (value.length < 2) {
return '昵称至少需要2个字符';
}
return null;
}
}
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../core/l10n/l10n.dart';
import '../../core/theme/design_tokens.dart';
import 'app_loading_indicator.dart';
@@ -7,16 +8,17 @@ class AppPullRefreshFeedback extends StatelessWidget {
const AppPullRefreshFeedback({
super.key,
required this.visible,
this.label = '正在刷新',
this.label,
this.margin = const EdgeInsets.only(top: AppSpacing.sm),
});
final bool visible;
final String label;
final String? label;
final EdgeInsetsGeometry margin;
@override
Widget build(BuildContext context) {
final resolvedLabel = label ?? context.l10n.commonRefreshing;
return IgnorePointer(
child: AnimatedOpacity(
opacity: visible ? 1 : 0,
@@ -44,7 +46,7 @@ class AppPullRefreshFeedback extends StatelessWidget {
),
const SizedBox(width: AppSpacing.sm),
Text(
label,
resolvedLabel,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: AppColors.slate600,
fontWeight: FontWeight.w500,
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../core/l10n/l10n.dart';
import '../../core/theme/design_tokens.dart';
import 'app_button.dart';
@@ -69,7 +70,7 @@ Future<T?> showAppSelectionSheet<T>(
child: SizedBox(
height: 48,
child: AppButton(
text: '取消',
text: context.l10n.commonCancel,
isOutlined: true,
onPressed: () => Navigator.of(sheetContext).pop(),
),
@@ -21,7 +21,7 @@ class AppBanner extends StatelessWidget {
Widget build(BuildContext context) {
if (!visible) return const SizedBox.shrink();
final config = ToastTypeConfig.fromType(type);
final config = ToastTypeConfig.fromType(context, type);
return Container(
width: double.infinity,
+4 -3
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/theme/design_tokens.dart';
enum MessageSender { user, ai }
@@ -77,11 +78,11 @@ class ChatBubble extends StatelessWidget {
String dateStr;
if (msgDate == today) {
dateStr = '今天';
dateStr = L10n.current.chatTimestampToday;
} else if (msgDate == today.subtract(const Duration(days: 1))) {
dateStr = '昨天';
dateStr = L10n.current.chatTimestampYesterday;
} else {
dateStr = '${time.month}${time.day}';
dateStr = L10n.current.chatTimestampMonthDay(time.month, time.day);
}
final timeStr =
+8 -4
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../core/l10n/l10n.dart';
import '../../core/theme/design_tokens.dart';
import 'app_button.dart';
@@ -7,10 +8,13 @@ Future<bool> showConfirmSheet(
BuildContext context, {
required String title,
required String message,
String confirmText = '确认',
String cancelText = '取消',
String? confirmText,
String? cancelText,
bool isDestructive = false,
}) async {
final l10n = context.l10n;
final resolvedConfirmText = confirmText ?? l10n.commonConfirm;
final resolvedCancelText = cancelText ?? l10n.commonCancel;
final result = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
@@ -64,7 +68,7 @@ Future<bool> showConfirmSheet(
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
confirmText,
resolvedConfirmText,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
@@ -78,7 +82,7 @@ Future<bool> showConfirmSheet(
SizedBox(
height: 52,
child: AppButton(
text: cancelText,
text: resolvedCancelText,
isOutlined: true,
onPressed: () => Navigator.of(sheetContext).pop(false),
),
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../core/l10n/l10n.dart';
import '../../core/theme/design_tokens.dart';
import 'app_button.dart';
@@ -74,7 +75,7 @@ Future<bool> showDestructiveActionSheet(
SizedBox(
height: 52,
child: AppButton(
text: '取消',
text: context.l10n.commonCancel,
isOutlined: true,
onPressed: () => Navigator.of(sheetContext).pop(false),
),
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../core/l10n/l10n.dart';
import '../../core/theme/design_tokens.dart';
import 'app_button.dart';
@@ -30,7 +31,7 @@ class ErrorRetrySurface extends StatelessWidget {
style: const TextStyle(color: AppColors.red500),
),
const SizedBox(height: AppSpacing.md),
AppButton(text: '重试', onPressed: onRetry),
AppButton(text: context.l10n.commonRetry, onPressed: onRetry),
],
),
),
+22 -13
View File
@@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../core/l10n/l10n.dart';
import '../../core/theme/design_tokens.dart';
import 'app_loading_indicator.dart';
@@ -37,10 +38,10 @@ class MessageComposer extends StatelessWidget {
required this.onHoldToSpeakCancel,
required this.textInputChild,
required this.recordingAnimation,
this.holdToSpeakText = '按住说话',
this.recordingText = '松开发送',
this.transcribingText = '语音识别中...',
this.recordingHintText = '松开发送,上滑取消',
this.holdToSpeakText,
this.recordingText,
this.transcribingText,
this.recordingHintText,
this.showRecordingInlineFeedback = true,
});
@@ -58,10 +59,10 @@ class MessageComposer extends StatelessWidget {
final VoidCallback onHoldToSpeakCancel;
final Widget textInputChild;
final Widget recordingAnimation;
final String holdToSpeakText;
final String recordingText;
final String transcribingText;
final String recordingHintText;
final String? holdToSpeakText;
final String? recordingText;
final String? transcribingText;
final String? recordingHintText;
final bool showRecordingInlineFeedback;
bool get _isHoldMode => mode == MessageComposerMode.holdToSpeak;
@@ -193,12 +194,20 @@ class MessageComposer extends StatelessWidget {
}
Widget _buildHoldToSpeakContent() {
final l10n = L10n.current;
final resolvedRecordingText =
recordingText ?? l10n.homeRecordingReleaseSend;
final resolvedRecordingHintText =
recordingHintText ?? l10n.homeRecordingHintReleaseSend;
final resolvedTranscribingText = transcribingText ?? l10n.homeTranscribing;
final resolvedHoldToSpeakText = holdToSpeakText ?? l10n.homeHoldToSpeakText;
if (_isRecording) {
if (!showRecordingInlineFeedback) {
return Align(
alignment: Alignment.center,
child: Text(
recordingText,
resolvedRecordingText,
style: const TextStyle(color: AppColors.slate700),
),
);
@@ -210,12 +219,12 @@ class MessageComposer extends StatelessWidget {
recordingAnimation,
const SizedBox(height: AppSpacing.xs),
Text(
recordingText,
resolvedRecordingText,
style: const TextStyle(color: AppColors.slate700),
),
const SizedBox(height: AppSpacing.xs),
Text(
recordingHintText,
resolvedRecordingHintText,
key: messageComposerRecordingHintKey,
style: const TextStyle(color: AppColors.slate500),
),
@@ -227,7 +236,7 @@ class MessageComposer extends StatelessWidget {
return Align(
alignment: Alignment.center,
child: Text(
transcribingText,
resolvedTranscribingText,
style: const TextStyle(color: AppColors.slate500),
),
);
@@ -236,7 +245,7 @@ class MessageComposer extends StatelessWidget {
return Align(
alignment: Alignment.center,
child: Text(
holdToSpeakText,
resolvedHoldToSpeakText,
style: const TextStyle(color: AppColors.slate500),
),
);
+1 -1
View File
@@ -91,7 +91,7 @@ class _ToastWidgetState extends State<_ToastWidget>
@override
Widget build(BuildContext context) {
final config = ToastTypeConfig.fromType(widget.type);
final config = ToastTypeConfig.fromType(context, widget.type);
return Positioned(
top: MediaQuery.of(context).padding.top + 12,
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../../../core/l10n/l10n.dart';
import 'toast_type.dart';
import '../../../core/theme/design_tokens.dart';
@@ -19,38 +20,41 @@ class ToastTypeConfig {
required this.icon,
});
static ToastTypeConfig fromType(ToastType type) => switch (type) {
ToastType.success => const ToastTypeConfig(
surfaceColor: AppColors.feedbackSuccessSurface,
borderColor: AppColors.feedbackSuccessBorder,
iconColor: AppColors.feedbackSuccessIcon,
textColor: AppColors.feedbackSuccessText,
label: '成功',
icon: Icons.check_circle_outline,
),
ToastType.warning => const ToastTypeConfig(
surfaceColor: AppColors.feedbackWarningSurface,
borderColor: AppColors.feedbackWarningBorder,
iconColor: AppColors.feedbackWarningIcon,
textColor: AppColors.feedbackWarningText,
label: '提醒',
icon: Icons.warning_amber_rounded,
),
ToastType.error => const ToastTypeConfig(
surfaceColor: AppColors.feedbackErrorSurface,
borderColor: AppColors.feedbackErrorBorder,
iconColor: AppColors.feedbackErrorIcon,
textColor: AppColors.feedbackErrorText,
label: '错误',
icon: Icons.error_outline,
),
ToastType.info => const ToastTypeConfig(
surfaceColor: AppColors.feedbackInfoSurface,
borderColor: AppColors.feedbackInfoBorder,
iconColor: AppColors.feedbackInfoIcon,
textColor: AppColors.feedbackInfoText,
label: '提示',
icon: Icons.info_outline,
),
};
static ToastTypeConfig fromType(BuildContext context, ToastType type) {
final l10n = context.l10n;
return switch (type) {
ToastType.success => ToastTypeConfig(
surfaceColor: AppColors.feedbackSuccessSurface,
borderColor: AppColors.feedbackSuccessBorder,
iconColor: AppColors.feedbackSuccessIcon,
textColor: AppColors.feedbackSuccessText,
label: l10n.toastLabelSuccess,
icon: Icons.check_circle_outline,
),
ToastType.warning => ToastTypeConfig(
surfaceColor: AppColors.feedbackWarningSurface,
borderColor: AppColors.feedbackWarningBorder,
iconColor: AppColors.feedbackWarningIcon,
textColor: AppColors.feedbackWarningText,
label: l10n.toastLabelWarning,
icon: Icons.warning_amber_rounded,
),
ToastType.error => ToastTypeConfig(
surfaceColor: AppColors.feedbackErrorSurface,
borderColor: AppColors.feedbackErrorBorder,
iconColor: AppColors.feedbackErrorIcon,
textColor: AppColors.feedbackErrorText,
label: l10n.toastLabelError,
icon: Icons.error_outline,
),
ToastType.info => ToastTypeConfig(
surfaceColor: AppColors.feedbackInfoSurface,
borderColor: AppColors.feedbackInfoBorder,
iconColor: AppColors.feedbackInfoIcon,
textColor: AppColors.feedbackInfoText,
label: l10n.toastLabelInfo,
icon: Icons.info_outline,
),
};
}
}