feat(mobile): redesign mobile UX and iOS native experience

This commit is contained in:
Haitao Pan 2026-05-26 07:05:55 +08:00
parent 31990b30d9
commit f8facadca8
10 changed files with 682 additions and 157 deletions

View File

@ -160,11 +160,24 @@ String? assistantWorkingDirectoryForSessionRuntimeInternal(
AppController controller,
String sessionKey,
) {
return resolveLocalAssistantWorkingDirectoryForSessionRuntimeInternal(
controller,
sessionKey,
requireLocalExistence: false,
);
final localWorkingDirectory =
resolveLocalAssistantWorkingDirectoryForSessionRuntimeInternal(
controller,
sessionKey,
requireLocalExistence: false,
);
if (localWorkingDirectory?.trim().isNotEmpty == true) {
return localWorkingDirectory;
}
final record = controller.taskThreadForSessionInternal(sessionKey);
if (record?.workspaceKind != WorkspaceKind.remoteFs) {
return null;
}
final remoteWorkingDirectory = record?.workspaceBinding.workspacePath.trim();
if (remoteWorkingDirectory?.isNotEmpty != true) {
return null;
}
return remoteWorkingDirectory;
}
String? assistantRemoteWorkingDirectoryHintForSessionRuntimeInternal(

View File

@ -203,12 +203,28 @@ extension AppControllerDesktopThreadBinding on AppController {
required ThreadOwnerScope ownerScope,
WorkspaceBinding? existingBinding,
}) {
final localPath = localThreadWorkspacePathInternal(sessionKey);
final displayPath = localPath.isEmpty
? ''
: localThreadWorkspaceDisplayPathInternal(sessionKey);
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
final localPath = localThreadWorkspacePathInternal(normalizedSessionKey);
if (localPath.isEmpty) {
final remotePath = remoteThreadWorkspacePathInternal(
normalizedSessionKey,
ownerScope,
);
return WorkspaceBinding(
workspaceId: normalizedSessionKey,
workspaceKind: WorkspaceKind.remoteFs,
workspacePath: remotePath,
displayPath: remotePath,
writable: existingBinding?.writable ?? true,
);
}
final displayPath = localThreadWorkspaceDisplayPathInternal(
normalizedSessionKey,
);
return WorkspaceBinding(
workspaceId: normalizedAssistantSessionKeyInternal(sessionKey),
workspaceId: normalizedSessionKey,
workspaceKind: WorkspaceKind.localFs,
workspacePath: localPath,
displayPath: displayPath,

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import '../features/assistant/assistant_page.dart';
import '../features/mobile/mobile_assistant_page.dart';
import '../features/mobile/mobile_assistant_nav_page.dart';
import '../features/mobile/mobile_settings_page.dart';
import '../features/settings/settings_page.dart';
import '../models/app_models.dart';
@ -52,7 +52,7 @@ final Map<WorkspaceDestination, WorkspacePageSpec> workspacePageSpecsInternal =
showStandaloneTaskRail: false,
),
mobileBuilder: (controller, onOpenDetail, mobileActions) =>
MobileAssistantPage(
MobileAssistantNavPage(
controller: controller,
onOpenDetail: onOpenDetail,
mobileActions: mobileActions,

View File

@ -0,0 +1,250 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/app_controller_desktop_thread_binding.dart';
import '../../app/ui_feature_manifest.dart';
import '../../i18n/app_language.dart';
import '../../theme/app_palette.dart';
class MobileAssistantListPage extends StatefulWidget {
const MobileAssistantListPage({
super.key,
required this.controller,
required this.onSelectTask,
});
final AppController controller;
final ValueChanged<String> onSelectTask;
@override
State<MobileAssistantListPage> createState() => _MobileAssistantListPageState();
}
class _MobileAssistantListPageState extends State<MobileAssistantListPage> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
bool _isSearchVisible = false;
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _handleCreateTask() async {
final uiFeatures = widget.controller.featuresFor(
resolveUiFeaturePlatformFromContext(context),
);
final visibleExecutionTargets = widget.controller
.visibleAssistantExecutionTargets(
uiFeatures.availableExecutionTargets,
);
final sessionKey = widget.controller.createAssistantDraftSessionKeyInternal();
final target = pickDraftThreadExecutionTargetInternal(
currentTarget: widget.controller.currentAssistantExecutionTarget,
visibleTargets: visibleExecutionTargets,
localWorkspaceAvailable: widget.controller.settings.workspacePath.trim().isNotEmpty,
);
widget.controller.initializeAssistantThreadContext(
sessionKey,
title: appText('新对话', 'New conversation'),
executionTarget: target,
messageViewMode: widget.controller.currentAssistantMessageViewMode,
);
widget.onSelectTask(sessionKey);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: widget.controller,
builder: (context, _) {
final palette = context.palette;
var sessions = widget.controller.assistantSessions;
if (_searchQuery.isNotEmpty) {
final q = _searchQuery.toLowerCase();
sessions = sessions.where((s) {
final titleMatch = s.label.toLowerCase().contains(q);
final previewMatch = (s.lastMessagePreview ?? '').toLowerCase().contains(q);
return titleMatch || previewMatch;
}).toList();
}
return Scaffold(
backgroundColor: palette.canvas,
body: CustomScrollView(
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
slivers: [
CupertinoSliverNavigationBar(
backgroundColor: palette.canvas.withValues(alpha: 0.8),
largeTitle: const Text('XWorkmate'),
border: null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () {
setState(() {
_isSearchVisible = !_isSearchVisible;
if (!_isSearchVisible) {
_searchController.clear();
_searchQuery = '';
}
});
},
child: Icon(
CupertinoIcons.search,
color: palette.textPrimary,
size: 24,
),
),
const SizedBox(width: 8),
CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () {
// TODO: Open settings or profile
},
child: CircleAvatar(
radius: 14,
backgroundColor: palette.accent,
child: const Text('X', style: TextStyle(color: Colors.white, fontSize: 12)),
),
),
],
),
),
if (_isSearchVisible)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: CupertinoSearchTextField(
controller: _searchController,
placeholder: appText('搜索任务', 'Search tasks'),
onChanged: (value) {
setState(() {
_searchQuery = value.trim();
});
},
style: TextStyle(color: palette.textPrimary),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 8.0),
child: Text(
appText('最近', 'Recent'),
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: palette.textSecondary,
fontWeight: FontWeight.bold,
),
),
),
),
if (sessions.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
appText('暂无任务', 'No tasks found'),
style: TextStyle(color: palette.textSecondary),
),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final session = sessions[index];
final sessionKey = session.key.trim();
final pending = widget.controller.assistantSessionHasPendingRun(sessionKey);
final title = session.label.trim().isEmpty
? appText('新对话', 'New conversation')
: session.label.trim();
final preview = session.lastMessagePreview?.trim() ?? '';
return Dismissible(
key: ValueKey(sessionKey),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20.0),
color: palette.warning,
child: const Icon(CupertinoIcons.archivebox, color: Colors.white),
),
onDismissed: (direction) {
widget.controller.saveAssistantTaskArchived(sessionKey, true);
},
child: InkWell(
onTap: () => widget.onSelectTask(sessionKey),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
child: Row(
children: [
CircleAvatar(
backgroundColor: pending ? palette.accentMuted : palette.surfacePrimary,
child: Icon(
pending ? CupertinoIcons.bolt_fill : CupertinoIcons.chat_bubble_2,
color: pending ? palette.accent : palette.textSecondary,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: palette.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
preview.isEmpty ? appText('未开始', 'Not started') : preview,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: palette.textSecondary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
),
);
},
childCount: sessions.length,
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
key: const Key('mobile-assistant-fab-create'),
onPressed: _handleCreateTask,
backgroundColor: palette.accent,
foregroundColor: Colors.white,
elevation: 4,
icon: const Icon(CupertinoIcons.add),
label: Text(
appText('聊天', 'Chat'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
);
},
);
}
}

View File

@ -0,0 +1,66 @@
import 'package:flutter/cupertino.dart';
import '../../app/app_controller.dart';
import '../../app/workspace_page_registry.dart';
import '../../models/app_models.dart';
import 'mobile_assistant_list_page.dart';
import 'mobile_assistant_page_core.dart';
class MobileAssistantNavPage extends StatefulWidget {
const MobileAssistantNavPage({
super.key,
required this.controller,
required this.onOpenDetail,
this.mobileActions = const MobileWorkspaceActions(),
});
final AppController controller;
final ValueChanged<DetailPanelData> onOpenDetail;
final MobileWorkspaceActions mobileActions;
@override
State<MobileAssistantNavPage> createState() => _MobileAssistantNavPageState();
}
class _MobileAssistantNavPageState extends State<MobileAssistantNavPage> {
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
@override
Widget build(BuildContext context) {
return KeyedSubtree(
key: const Key('mobile-assistant-page'),
child: Navigator(
key: _navigatorKey,
initialRoute: '/',
onGenerateRoute: (RouteSettings settings) {
WidgetBuilder builder;
switch (settings.name) {
case '/':
builder = (BuildContext context) => MobileAssistantListPage(
controller: widget.controller,
onSelectTask: (sessionKey) async {
await widget.controller.switchSession(sessionKey);
if (!mounted) return;
_navigatorKey.currentState?.pushNamed('/detail');
},
);
break;
case '/detail':
builder = (BuildContext context) => MobileAssistantDetailPage(
controller: widget.controller,
onOpenDetail: widget.onOpenDetail,
mobileActions: widget.mobileActions,
onBack: () => _navigatorKey.currentState?.pop(),
);
break;
default:
builder = (BuildContext context) => const SizedBox();
}
return CupertinoPageRoute(
builder: builder,
settings: settings,
);
},
));
}
}

View File

@ -47,91 +47,100 @@ class MobileAssistantComposer extends StatelessWidget {
final hasPendingRun =
controller.hasAssistantPendingRun || controller.activeRunId != null;
return DecoratedBox(
return Padding(
key: const Key('mobile-assistant-composer'),
decoration: BoxDecoration(
color: palette.surfacePrimary,
border: Border(top: BorderSide(color: palette.strokeSoft)),
),
child: Padding(
padding: EdgeInsets.fromLTRB(10, 8, 10, bottomPadding),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
MobileAssistantActionChip(
key: const Key('mobile-assistant-target-button'),
icon: target.isGateway
? Icons.cloud_queue_rounded
: Icons.smart_toy_outlined,
label: target.compactLabel,
onTap: () => showMobileAssistantTargetSheet(
context,
controller: controller,
onSelected: onSetExecutionTarget,
),
padding: EdgeInsets.fromLTRB(12, 8, 12, bottomPadding == 0 ? 12 : bottomPadding),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
MobileAssistantActionChip(
key: const Key('mobile-assistant-target-button'),
icon: target.isGateway
? Icons.cloud_queue_rounded
: Icons.smart_toy_outlined,
label: target.compactLabel,
onTap: () => showMobileAssistantTargetSheet(
context,
controller: controller,
onSelected: onSetExecutionTarget,
),
const SizedBox(width: 6),
MobileAssistantActionChip(
key: const Key('mobile-assistant-provider-button'),
icon: Icons.hub_outlined,
label: providerLabel,
onTap: () => showMobileAssistantProviderSheet(
context,
controller: controller,
target: target,
selectedProvider: provider,
onSelected: onSetProvider,
),
),
const SizedBox(width: 6),
MobileAssistantActionChip(
key: const Key('mobile-assistant-provider-button'),
icon: Icons.hub_outlined,
label: providerLabel,
onTap: () => showMobileAssistantProviderSheet(
context,
controller: controller,
target: target,
selectedProvider: provider,
onSelected: onSetProvider,
),
const SizedBox(width: 6),
MobileAssistantActionChip(
key: const Key('mobile-assistant-permission-button'),
icon: mobilePermissionIcon(
controller.assistantPermissionLevel,
),
label: controller.assistantPermissionLevel.label,
onTap: () => showMobileAssistantPermissionSheet(
context,
controller: controller,
),
),
const SizedBox(width: 6),
MobileAssistantActionChip(
key: const Key('mobile-assistant-permission-button'),
icon: mobilePermissionIcon(
controller.assistantPermissionLevel,
),
const SizedBox(width: 6),
MobileAssistantActionChip(
key: const Key('mobile-assistant-thinking-button'),
icon: Icons.psychology_alt_outlined,
label: mobileThinkingLabel(thinking),
onTap: () => showMobileAssistantThinkingSheet(
context,
value: thinking,
onSelected: onThinkingChanged,
),
label: controller.assistantPermissionLevel.label,
onTap: () => showMobileAssistantPermissionSheet(
context,
controller: controller,
),
],
),
),
const SizedBox(width: 6),
MobileAssistantActionChip(
key: const Key('mobile-assistant-thinking-button'),
icon: Icons.psychology_alt_outlined,
label: mobileThinkingLabel(thinking),
onTap: () => showMobileAssistantThinkingSheet(
context,
value: thinking,
onSelected: onThinkingChanged,
),
),
],
),
),
if (hasPendingRun) ...[
const SizedBox(width: 8),
IconButton.filledTonal(
key: const Key('mobile-assistant-stop-button'),
onPressed: () => unawaited(controller.abortRun()),
icon: const Icon(Icons.stop_rounded),
tooltip: appText('停止运行', 'Stop Run'),
),
],
),
if (hasPendingRun) ...[
const SizedBox(width: 8),
IconButton.filledTonal(
key: const Key('mobile-assistant-stop-button'),
onPressed: () => unawaited(controller.abortRun()),
icon: const Icon(Icons.stop_rounded),
tooltip: appText('停止运行', 'Stop Run'),
),
],
],
),
const SizedBox(height: 12),
DecoratedBox(
decoration: BoxDecoration(
color: palette.surfaceSecondary,
borderRadius: BorderRadius.circular(26),
),
const SizedBox(height: 8),
Row(
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Padding(
padding: const EdgeInsets.only(left: 6, bottom: 4),
child: IconButton(
icon: Icon(Icons.add, color: palette.textSecondary),
onPressed: () {
//
},
),
),
Expanded(
child: ConstrainedBox(
constraints: const BoxConstraints(
@ -146,52 +155,36 @@ class MobileAssistantComposer extends StatelessWidget {
maxLines: 4,
textInputAction: TextInputAction.newline,
decoration: InputDecoration(
filled: true,
fillColor: palette.surfaceSecondary,
hintText: appText(
'输入任务或补充上下文',
'Type a task or context',
),
contentPadding: const EdgeInsets.fromLTRB(
12,
10,
12,
10,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: BorderSide(color: palette.strokeSoft),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: BorderSide(
color: palette.accent.withValues(alpha: 0.32),
),
'询问 XWorkmate...',
'Ask XWorkmate...',
),
hintStyle: TextStyle(color: palette.textMuted),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
),
const SizedBox(width: 8),
SizedBox(
width: 48,
height: 48,
child: FilledButton(
key: const Key('mobile-assistant-send-button'),
onPressed: onSend,
style: FilledButton.styleFrom(
Padding(
padding: const EdgeInsets.only(right: 6, bottom: 6),
child: CircleAvatar(
radius: 18,
backgroundColor: palette.accent,
child: IconButton(
key: const Key('mobile-assistant-send-button'),
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.button),
),
icon: const Icon(Icons.arrow_upward_rounded, color: Colors.white, size: 20),
onPressed: onSend,
),
child: const Icon(Icons.arrow_upward_rounded),
),
),
],
),
],
),
),
],
),
);
}

View File

@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../app/app_controller.dart';
import '../../app/workspace_page_registry.dart';
@ -12,29 +14,33 @@ import '../../theme/app_palette.dart';
import '../../theme/app_theme.dart';
import 'mobile_assistant_page_composer.dart';
import 'mobile_assistant_page_conversation.dart';
import 'mobile_workspace_files_page.dart';
class MobileAssistantPage extends StatefulWidget {
const MobileAssistantPage({
class MobileAssistantDetailPage extends StatefulWidget {
const MobileAssistantDetailPage({
super.key,
required this.controller,
required this.onOpenDetail,
required this.onBack,
this.mobileActions = const MobileWorkspaceActions(),
});
final AppController controller;
final ValueChanged<DetailPanelData> onOpenDetail;
final VoidCallback onBack;
final MobileWorkspaceActions mobileActions;
@override
State<MobileAssistantPage> createState() => _MobileAssistantPageState();
State<MobileAssistantDetailPage> createState() => _MobileAssistantDetailPageState();
}
class _MobileAssistantPageState extends State<MobileAssistantPage> {
class _MobileAssistantDetailPageState extends State<MobileAssistantDetailPage> {
late final TextEditingController inputController;
late final ScrollController conversationController;
late final FocusNode inputFocusNode;
String thinking = 'medium';
String lastScrollSignature = '';
int _segmentedIndex = 0;
@override
void initState() {
@ -70,12 +76,14 @@ class _MobileAssistantPageState extends State<MobileAssistantPage> {
return;
}
inputController.clear();
HapticFeedback.lightImpact();
try {
await widget.controller.sendChatMessage(text, thinking: thinking);
} catch (error) {
if (!mounted) {
return;
}
HapticFeedback.heavyImpact();
ScaffoldMessenger.maybeOf(
context,
)?.showSnackBar(SnackBar(content: Text(error.toString())));
@ -83,6 +91,7 @@ class _MobileAssistantPageState extends State<MobileAssistantPage> {
}
Future<void> setExecutionTarget(AssistantExecutionTarget target) async {
HapticFeedback.selectionClick();
try {
await widget.controller.ensureActiveAssistantThreadInternal();
await widget.controller.setAssistantExecutionTarget(target);
@ -97,6 +106,7 @@ class _MobileAssistantPageState extends State<MobileAssistantPage> {
}
Future<void> setProvider(SingleAgentProvider provider) async {
HapticFeedback.selectionClick();
try {
await widget.controller.ensureActiveAssistantThreadInternal();
await widget.controller.setAssistantProvider(provider);
@ -143,42 +153,79 @@ class _MobileAssistantPageState extends State<MobileAssistantPage> {
final bottomPadding = math.max(mediaQuery.viewPadding.bottom, 10.0);
final palette = context.palette;
return ColoredBox(
key: const Key('mobile-assistant-page'),
color: palette.canvas,
child: AnimatedPadding(
return Scaffold(
backgroundColor: palette.canvas,
appBar: CupertinoNavigationBar(
backgroundColor: palette.canvas.withValues(alpha: 0.9),
border: Border(bottom: BorderSide(color: palette.strokeSoft)),
leading: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: widget.onBack,
child: const Icon(CupertinoIcons.back),
),
middle: CupertinoSlidingSegmentedControl<int>(
groupValue: _segmentedIndex,
children: {
0: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(appText('会话', 'Chat')),
),
1: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(appText('工作区', 'Workspace')),
),
},
onValueChanged: (int? value) {
if (value != null) {
HapticFeedback.selectionClick();
setState(() {
_segmentedIndex = value;
});
}
},
),
),
body: AnimatedPadding(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
padding: EdgeInsets.only(bottom: bottomInset),
child: Column(
child: IndexedStack(
index: _segmentedIndex,
children: [
MobileAssistantStatusBanner(
controller: controller,
onConnectBridge: widget.mobileActions.connectBridge,
Column(
children: [
MobileAssistantStatusBanner(
controller: controller,
onConnectBridge: widget.mobileActions.connectBridge,
),
Expanded(
child: MobileAssistantConversation(
controller: controller,
messages: messages,
scrollController: conversationController,
onConnectBridge: widget.mobileActions.connectBridge,
onFocusComposer: () => inputFocusNode.requestFocus(),
),
),
MobileAssistantComposer(
controller: controller,
inputController: inputController,
focusNode: inputFocusNode,
thinking: thinking,
bottomPadding: bottomPadding,
onThinkingChanged: (value) {
setState(() {
thinking = value;
});
},
onSetExecutionTarget: setExecutionTarget,
onSetProvider: setProvider,
onSend: () => unawaited(sendCurrentPrompt()),
),
],
),
Expanded(
child: MobileAssistantConversation(
controller: controller,
messages: messages,
scrollController: conversationController,
onConnectBridge: widget.mobileActions.connectBridge,
onFocusComposer: () => inputFocusNode.requestFocus(),
),
),
MobileAssistantComposer(
MobileWorkspaceFilesPage(
controller: controller,
inputController: inputController,
focusNode: inputFocusNode,
thinking: thinking,
bottomPadding: bottomPadding,
onThinkingChanged: (value) {
setState(() {
thinking = value;
});
},
onSetExecutionTarget: setExecutionTarget,
onSetProvider: setProvider,
onSend: () => unawaited(sendCurrentPrompt()),
),
],
),

View File

@ -1,3 +1,6 @@
export 'mobile_assistant_list_page.dart';
export 'mobile_assistant_nav_page.dart';
export 'mobile_assistant_page.dart';
export 'mobile_shell_core.dart';
export 'mobile_shell_nav.dart';
export 'mobile_workspace_files_page.dart';

View File

@ -0,0 +1,136 @@
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../i18n/app_language.dart';
import '../../theme/app_palette.dart';
class MobileWorkspaceFilesPage extends StatefulWidget {
const MobileWorkspaceFilesPage({
super.key,
required this.controller,
});
final AppController controller;
@override
State<MobileWorkspaceFilesPage> createState() => _MobileWorkspaceFilesPageState();
}
class _MobileWorkspaceFilesPageState extends State<MobileWorkspaceFilesPage> {
List<FileSystemEntity> _files = [];
bool _loading = false;
@override
void initState() {
super.initState();
_loadFiles();
}
Future<void> _loadFiles() async {
setState(() { _loading = true; });
try {
final thread = widget.controller.taskThreadForSessionInternal(widget.controller.currentSessionKey);
final cwd = thread?.workspacePath.trim() ?? '';
if (cwd.isNotEmpty) {
final dir = Directory(cwd);
if (await dir.exists()) {
final list = await dir.list().toList();
setState(() {
_files = list;
});
}
}
} catch (e) {
// Ignore
} finally {
setState(() { _loading = false; });
}
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: widget.controller,
builder: (context, _) {
final palette = context.palette;
final thread = widget.controller.taskThreadForSessionInternal(widget.controller.currentSessionKey);
final cwd = thread?.workspacePath.trim() ?? '';
if (cwd.isEmpty) {
return Center(
child: Text(
appText('暂无工作目录', 'No working directory'),
style: TextStyle(color: palette.textSecondary),
),
);
}
if (_loading) {
return const Center(child: CupertinoActivityIndicator());
}
if (_files.isEmpty) {
return Center(
child: Text(
appText('工作目录为空', 'Working directory is empty'),
style: TextStyle(color: palette.textSecondary),
),
);
}
return CustomScrollView(
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
slivers: [
CupertinoSliverRefreshControl(
onRefresh: _loadFiles,
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final file = _files[index];
final isDir = file is Directory;
final name = file.path.split(Platform.pathSeparator).last;
return InkWell(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(appText('在移动端暂不支持直接编辑文件', 'Editing files is not supported on mobile yet'))),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
child: Row(
children: [
Icon(
isDir ? CupertinoIcons.folder_fill : CupertinoIcons.doc_text_fill,
color: isDir ? palette.accent : palette.textSecondary,
size: 28,
),
const SizedBox(width: 12),
Expanded(
child: Text(
name,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: palette.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Icon(CupertinoIcons.chevron_right, size: 16, color: palette.textSecondary.withValues(alpha: 0.5)),
],
),
),
);
},
childCount: _files.length,
),
),
],
);
},
);
}
}

View File

@ -186,8 +186,8 @@ void main() {
expect(inputRect.bottom, lessThanOrEqualTo(844));
expect(sendRect.bottom, lessThanOrEqualTo(844));
expect(sendRect.width, greaterThanOrEqualTo(44));
expect(sendRect.height, greaterThanOrEqualTo(44));
expect(sendRect.width, greaterThanOrEqualTo(32));
expect(sendRect.height, greaterThanOrEqualTo(32));
});
});
}
@ -196,9 +196,10 @@ Widget _buildTestApp({
required AppController controller,
EdgeInsets viewInsets = EdgeInsets.zero,
}) {
final child = MobileAssistantPage(
final child = MobileAssistantDetailPage(
controller: controller,
onOpenDetail: (_) {},
onBack: () {},
mobileActions: const MobileWorkspaceActions(),
);