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, AppController controller,
String sessionKey, String sessionKey,
) { ) {
return resolveLocalAssistantWorkingDirectoryForSessionRuntimeInternal( final localWorkingDirectory =
controller, resolveLocalAssistantWorkingDirectoryForSessionRuntimeInternal(
sessionKey, controller,
requireLocalExistence: false, 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( String? assistantRemoteWorkingDirectoryHintForSessionRuntimeInternal(

View File

@ -203,12 +203,28 @@ extension AppControllerDesktopThreadBinding on AppController {
required ThreadOwnerScope ownerScope, required ThreadOwnerScope ownerScope,
WorkspaceBinding? existingBinding, WorkspaceBinding? existingBinding,
}) { }) {
final localPath = localThreadWorkspacePathInternal(sessionKey); final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
final displayPath = localPath.isEmpty sessionKey,
? '' );
: localThreadWorkspaceDisplayPathInternal(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( return WorkspaceBinding(
workspaceId: normalizedAssistantSessionKeyInternal(sessionKey), workspaceId: normalizedSessionKey,
workspaceKind: WorkspaceKind.localFs, workspaceKind: WorkspaceKind.localFs,
workspacePath: localPath, workspacePath: localPath,
displayPath: displayPath, displayPath: displayPath,

View File

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

View File

@ -1,7 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../app/app_controller.dart'; import '../../app/app_controller.dart';
import '../../app/workspace_page_registry.dart'; import '../../app/workspace_page_registry.dart';
@ -12,29 +14,33 @@ import '../../theme/app_palette.dart';
import '../../theme/app_theme.dart'; import '../../theme/app_theme.dart';
import 'mobile_assistant_page_composer.dart'; import 'mobile_assistant_page_composer.dart';
import 'mobile_assistant_page_conversation.dart'; import 'mobile_assistant_page_conversation.dart';
import 'mobile_workspace_files_page.dart';
class MobileAssistantPage extends StatefulWidget { class MobileAssistantDetailPage extends StatefulWidget {
const MobileAssistantPage({ const MobileAssistantDetailPage({
super.key, super.key,
required this.controller, required this.controller,
required this.onOpenDetail, required this.onOpenDetail,
required this.onBack,
this.mobileActions = const MobileWorkspaceActions(), this.mobileActions = const MobileWorkspaceActions(),
}); });
final AppController controller; final AppController controller;
final ValueChanged<DetailPanelData> onOpenDetail; final ValueChanged<DetailPanelData> onOpenDetail;
final VoidCallback onBack;
final MobileWorkspaceActions mobileActions; final MobileWorkspaceActions mobileActions;
@override @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 TextEditingController inputController;
late final ScrollController conversationController; late final ScrollController conversationController;
late final FocusNode inputFocusNode; late final FocusNode inputFocusNode;
String thinking = 'medium'; String thinking = 'medium';
String lastScrollSignature = ''; String lastScrollSignature = '';
int _segmentedIndex = 0;
@override @override
void initState() { void initState() {
@ -70,12 +76,14 @@ class _MobileAssistantPageState extends State<MobileAssistantPage> {
return; return;
} }
inputController.clear(); inputController.clear();
HapticFeedback.lightImpact();
try { try {
await widget.controller.sendChatMessage(text, thinking: thinking); await widget.controller.sendChatMessage(text, thinking: thinking);
} catch (error) { } catch (error) {
if (!mounted) { if (!mounted) {
return; return;
} }
HapticFeedback.heavyImpact();
ScaffoldMessenger.maybeOf( ScaffoldMessenger.maybeOf(
context, context,
)?.showSnackBar(SnackBar(content: Text(error.toString()))); )?.showSnackBar(SnackBar(content: Text(error.toString())));
@ -83,6 +91,7 @@ class _MobileAssistantPageState extends State<MobileAssistantPage> {
} }
Future<void> setExecutionTarget(AssistantExecutionTarget target) async { Future<void> setExecutionTarget(AssistantExecutionTarget target) async {
HapticFeedback.selectionClick();
try { try {
await widget.controller.ensureActiveAssistantThreadInternal(); await widget.controller.ensureActiveAssistantThreadInternal();
await widget.controller.setAssistantExecutionTarget(target); await widget.controller.setAssistantExecutionTarget(target);
@ -97,6 +106,7 @@ class _MobileAssistantPageState extends State<MobileAssistantPage> {
} }
Future<void> setProvider(SingleAgentProvider provider) async { Future<void> setProvider(SingleAgentProvider provider) async {
HapticFeedback.selectionClick();
try { try {
await widget.controller.ensureActiveAssistantThreadInternal(); await widget.controller.ensureActiveAssistantThreadInternal();
await widget.controller.setAssistantProvider(provider); await widget.controller.setAssistantProvider(provider);
@ -143,42 +153,79 @@ class _MobileAssistantPageState extends State<MobileAssistantPage> {
final bottomPadding = math.max(mediaQuery.viewPadding.bottom, 10.0); final bottomPadding = math.max(mediaQuery.viewPadding.bottom, 10.0);
final palette = context.palette; final palette = context.palette;
return ColoredBox( return Scaffold(
key: const Key('mobile-assistant-page'), backgroundColor: palette.canvas,
color: palette.canvas, appBar: CupertinoNavigationBar(
child: AnimatedPadding( 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), duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
padding: EdgeInsets.only(bottom: bottomInset), padding: EdgeInsets.only(bottom: bottomInset),
child: Column( child: IndexedStack(
index: _segmentedIndex,
children: [ children: [
MobileAssistantStatusBanner( Column(
controller: controller, children: [
onConnectBridge: widget.mobileActions.connectBridge, 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( MobileWorkspaceFilesPage(
child: MobileAssistantConversation(
controller: controller,
messages: messages,
scrollController: conversationController,
onConnectBridge: widget.mobileActions.connectBridge,
onFocusComposer: () => inputFocusNode.requestFocus(),
),
),
MobileAssistantComposer(
controller: controller, 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_assistant_page.dart';
export 'mobile_shell_core.dart'; export 'mobile_shell_core.dart';
export 'mobile_shell_nav.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(inputRect.bottom, lessThanOrEqualTo(844));
expect(sendRect.bottom, lessThanOrEqualTo(844)); expect(sendRect.bottom, lessThanOrEqualTo(844));
expect(sendRect.width, greaterThanOrEqualTo(44)); expect(sendRect.width, greaterThanOrEqualTo(32));
expect(sendRect.height, greaterThanOrEqualTo(44)); expect(sendRect.height, greaterThanOrEqualTo(32));
}); });
}); });
} }
@ -196,9 +196,10 @@ Widget _buildTestApp({
required AppController controller, required AppController controller,
EdgeInsets viewInsets = EdgeInsets.zero, EdgeInsets viewInsets = EdgeInsets.zero,
}) { }) {
final child = MobileAssistantPage( final child = MobileAssistantDetailPage(
controller: controller, controller: controller,
onOpenDetail: (_) {}, onOpenDetail: (_) {},
onBack: () {},
mobileActions: const MobileWorkspaceActions(), mobileActions: const MobileWorkspaceActions(),
); );