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
@@ -3,6 +3,8 @@ import 'dart:io';
import 'package:flutter/services.dart';
import 'package:record/record.dart';
import '../../../core/l10n/l10n.dart';
abstract class VoiceRecorder {
Future<void> start();
Future<String?> stop();
@@ -22,10 +24,10 @@ class RecordVoiceRecorder implements VoiceRecorder {
try {
hasPermission = await _recorder.hasPermission();
} on MissingPluginException catch (_) {
throw StateError('录音组件未加载,请完全重启 App 后重试');
throw StateError(L10n.current.homeRecorderPluginUnavailable);
}
if (!hasPermission) {
throw StateError('录音权限未授权');
throw StateError(L10n.current.homeRecorderPermissionDenied);
}
final fileName =
@@ -42,7 +44,7 @@ class RecordVoiceRecorder implements VoiceRecorder {
path: path,
);
} on MissingPluginException catch (_) {
throw StateError('录音组件未加载,请完全重启 App 后重试');
throw StateError(L10n.current.homeRecorderPluginUnavailable);
}
}
@@ -52,7 +54,7 @@ class RecordVoiceRecorder implements VoiceRecorder {
try {
stoppedPath = await _recorder.stop();
} on MissingPluginException catch (_) {
throw StateError('录音组件未加载,请完全重启 App 后重试');
throw StateError(L10n.current.homeRecorderPluginUnavailable);
}
return stoppedPath ?? _currentPath;
}
@@ -1,7 +1,7 @@
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/router/app_routes.dart';
import '../../../../app/router/app_routes.dart';
enum HomeReturnAction { pop, goHome, goHomeForDock }
@@ -5,10 +5,11 @@ import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import '../../../../core/api/api_exception.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/router/app_route_observer.dart';
import '../../../../core/router/app_routes.dart';
import '../../../../core/network/api_exception.dart';
import '../../../../app/di/injection.dart';
import '../../../../app/router/app_route_observer.dart';
import '../../../../app/router/app_routes.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../chat/presentation/bloc/agent_stage.dart';
import '../../../chat/presentation/bloc/chat_bloc.dart';
@@ -32,7 +33,7 @@ import '../widgets/home_unread_badge.dart';
part 'home_screen_interactions.dart';
///
/// Layout constants.
const _defaultPadding = 20.0;
const _itemSpacing = 16.0;
const _cancelThreshold = -(AppSpacing.xxl + AppSpacing.xxl);
@@ -46,7 +47,7 @@ const homeConversationStageKey = ValueKey('home_conversation_stage');
const homeBottomInputStackKey = ValueKey('home_bottom_input_stack');
const homeEmptyStateAmbientKey = ValueKey('home_empty_state_ambient');
///
/// Color constants.
const _chatBgColor = AppColors.slate50;
class HomeScreen extends StatefulWidget {
@@ -331,7 +332,7 @@ class _HomeScreenState extends State<HomeScreen>
padding: const EdgeInsets.only(
bottom: _itemSpacing,
),
child: HomeChatItemRenderer.build(item),
child: HomeChatItemRenderer.build(context, item),
),
],
);
@@ -394,7 +395,11 @@ class _HomeScreenState extends State<HomeScreen>
if (hasEarlierHistory) {
await _loadMoreHistoryPreservingViewport(chatBloc);
} else {
Toast.show(context, '没有更早的历史记录了', type: ToastType.info);
Toast.show(
context,
context.l10n.homeNoEarlierHistory,
type: ToastType.info,
);
}
_applyViewportDecision(
_dispatchViewportEvent(
@@ -23,7 +23,11 @@ extension _HomeScreenInteractions on _HomeScreenState {
_isCancelGestureActive = false;
});
if (showToast) {
Toast.show(context, '已取消', type: ToastType.info);
Toast.show(
context,
context.l10n.homeRecordingCanceled,
type: ToastType.info,
);
}
}
@@ -75,7 +79,7 @@ extension _HomeScreenInteractions on _HomeScreenState {
return;
}
if (canceled) {
Toast.show(context, '已请求停止', type: ToastType.info);
Toast.show(context, context.l10n.homeStopRequested, type: ToastType.info);
}
}
@@ -145,7 +149,7 @@ extension _HomeScreenInteractions on _HomeScreenState {
_isCancelGestureActive = false;
});
if (audioPath == null || audioPath.isEmpty) {
throw StateError('录音失败,请重试');
throw StateError(context.l10n.errorGenericSafe);
}
final transcript = await _transcribeAudio(audioPath);
if (!mounted) {
@@ -153,7 +157,11 @@ extension _HomeScreenInteractions on _HomeScreenState {
}
final normalizedTranscript = transcript.trim();
if (normalizedTranscript.isEmpty) {
Toast.show(context, '未识别到有效语音,请靠近麦克风并连续说话后重试', type: ToastType.error);
Toast.show(
context,
context.l10n.homeNoValidSpeech,
type: ToastType.error,
);
return;
}
_messageController.text = normalizedTranscript;
@@ -196,7 +204,7 @@ extension _HomeScreenInteractions on _HomeScreenState {
}
final raw = error.toString();
if (raw.startsWith('Instance of')) {
return '请求失败,请稍后重试';
return context.l10n.errorGenericSafe;
}
return raw.replaceFirst('Bad state: ', '');
}
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
class HomeSheet extends StatelessWidget {
@@ -57,14 +58,14 @@ class HomeSheet extends StatelessWidget {
_buildOptionCard(
context: context,
icon: LucideIcons.camera,
label: '拍照',
label: context.l10n.homeSheetTakePhoto,
onTap: () => _handleCameraTap(context),
),
const SizedBox(width: 24),
_buildOptionCard(
context: context,
icon: LucideIcons.image,
label: '相册',
label: context.l10n.homeSheetPhotoLibrary,
onTap: () => _handlePhotoTap(context),
),
],
@@ -3,11 +3,12 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/utils/tool_name_localizer.dart';
import '../../../../core/utils/tool_name_localizer.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../chat/data/models/chat_list_item.dart';
import '../../../chat/ui/widgets/ui_schema_renderer.dart';
import '../../../ui_schema/presentation/widgets/ui_schema_renderer.dart';
const _messagePaddingH = 13.0;
const _messagePaddingV = 9.0;
@@ -19,12 +20,12 @@ const _toolResultWidthFactor = 0.9;
const _iconSize = 24.0;
class HomeChatItemRenderer {
static Widget build(ChatListItem item) {
static Widget build(BuildContext context, ChatListItem item) {
switch (item.type) {
case ChatItemType.message:
return _buildMessageItem(item as TextMessageItem);
case ChatItemType.toolCall:
return _buildToolCallItem(item as ToolCallItem);
return _buildToolCallItem(context, item as ToolCallItem);
case ChatItemType.toolResult:
return _buildToolResultItem(item as ToolResultItem);
}
@@ -198,25 +199,26 @@ class HomeChatItemRenderer {
);
}
static Widget _buildToolCallItem(ToolCallItem item) {
static Widget _buildToolCallItem(BuildContext context, ToolCallItem item) {
final l10n = context.l10n;
final (statusText, statusColor, statusIcon) = switch (item.status) {
ToolCallStatus.pending => (
'工具准备中',
l10n.homeToolPreparing,
AppColors.slate500,
LucideIcons.clock,
),
ToolCallStatus.executing => (
'任务执行中',
l10n.homeToolExecuting,
AppColors.blue600,
LucideIcons.loader,
),
ToolCallStatus.error => (
item.errorMessage ?? '执行失败',
item.errorMessage ?? l10n.homeToolExecutionFailed,
AppColors.red600,
LucideIcons.alertCircle,
),
ToolCallStatus.completed => (
'已完成',
l10n.homeToolCompleted,
AppColors.emerald600,
LucideIcons.checkCircle,
),
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/message_composer.dart';
@@ -94,12 +95,14 @@ class HomeComposerStack extends StatelessWidget {
onHoldToSpeakEnd: onHoldToSpeakEnd,
onHoldToSpeakMoveUpdate: onHoldToSpeakMoveUpdate,
onHoldToSpeakCancel: onHoldToSpeakCancel,
textInputChild: _buildTextInputContent(),
textInputChild: _buildTextInputContent(context),
recordingAnimation: const SizedBox.shrink(),
recordingText: isCancelGestureActive ? '松手取消' : '松手发送',
recordingText: isCancelGestureActive
? context.l10n.homeRecordingReleaseCancel
: context.l10n.homeRecordingReleaseSend,
recordingHintText: isCancelGestureActive
? '松开取消'
: '松开发送,上滑取消',
? context.l10n.homeRecordingHintReleaseCancel
: context.l10n.homeRecordingHintReleaseSend,
showRecordingInlineFeedback: false,
);
},
@@ -111,9 +114,9 @@ class HomeComposerStack extends StatelessWidget {
);
}
Widget _buildTextInputContent() {
Widget _buildTextInputContent(BuildContext context) {
if (isTranscribing) {
return _buildTranscribingIndicator();
return _buildTranscribingIndicator(context);
}
return SizedBox.expand(
child: Align(
@@ -129,8 +132,8 @@ class HomeComposerStack extends StatelessWidget {
color: AppColors.slate900,
),
textAlignVertical: TextAlignVertical.center,
decoration: const InputDecoration(
hintText: '输入消息...',
decoration: InputDecoration(
hintText: context.l10n.homeInputHint,
hintStyle: TextStyle(
fontSize: AppSpacing.lg,
height: 1,
@@ -152,7 +155,7 @@ class HomeComposerStack extends StatelessWidget {
);
}
Widget _buildTranscribingIndicator() {
Widget _buildTranscribingIndicator(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@@ -170,10 +173,10 @@ class HomeComposerStack extends StatelessWidget {
const SizedBox(width: AppSpacing.sm),
_buildWaveDots(),
const SizedBox(width: AppSpacing.sm),
const Expanded(
Expanded(
child: Text(
'语音识别中...',
style: TextStyle(
context.l10n.homeTranscribing,
style: const TextStyle(
fontSize: 14,
color: AppColors.blue600,
fontWeight: FontWeight.w600,
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
@@ -45,11 +46,16 @@ class HomeDateDivider extends StatelessWidget {
@override
Widget build(BuildContext context) {
final now = DateTime.now();
final weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
final weekday = weekdays[date.weekday - 1];
final label = date.year == now.year
? '${date.month}${date.day}$weekday'
: '${date.year}${date.month}${date.day}$weekday';
? context.l10n.homeDateLabelNoYear(date.month, date.day, weekday)
: context.l10n.homeDateLabelWithYear(
date.year,
date.month,
date.day,
weekday,
);
return Container(
padding: const EdgeInsets.symmetric(vertical: 12),
@@ -87,9 +93,9 @@ class HomeLoadMoreButton extends StatelessWidget {
color: AppColors.slate400,
trackColor: AppColors.slate200,
)
: const Text(
'查看历史',
style: TextStyle(fontSize: 12, color: AppColors.slate400),
: Text(
context.l10n.homeViewHistory,
style: const TextStyle(fontSize: 12, color: AppColors.slate400),
),
),
);
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
const _recordingCancelTopColor = AppColors.warningBackground;
@@ -30,7 +31,9 @@ class HomeRecordingOverlay extends StatelessWidget {
final labelColor = isCancel
? _recordingCancelLabelColor
: _recordingActiveLabelColor;
final label = isCancel ? '松手取消' : '松手发送,上移取消';
final label = isCancel
? context.l10n.homeRecordingReleaseCancel
: context.l10n.homeRecordingHintReleaseSend;
return IgnorePointer(
child: Align(
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_pressable.dart';
@@ -30,7 +31,7 @@ class HomeUnreadBadge extends StatelessWidget {
],
),
child: Text(
'$count条新消息',
context.l10n.homeUnreadMessages(count),
style: const TextStyle(
color: AppColors.white,
fontSize: 12,