feat(apps): 重构 UI 架构为 presentation 层并新增 l10n 国际化支持
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user