feat(mobile): redesign mobile UX and iOS native experience
This commit is contained in:
parent
31990b30d9
commit
f8facadca8
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
250
lib/features/mobile/mobile_assistant_list_page.dart
Normal file
250
lib/features/mobile/mobile_assistant_list_page.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
66
lib/features/mobile/mobile_assistant_nav_page.dart
Normal file
66
lib/features/mobile/mobile_assistant_nav_page.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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()),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -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';
|
||||
|
||||
136
lib/features/mobile/mobile_workspace_files_page.dart
Normal file
136
lib/features/mobile/mobile_workspace_files_page.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user