feat: 添加自动化任务(automation_jobs)功能模块

This commit is contained in:
qzl
2026-03-24 12:38:11 +08:00
parent f4b7eb7e09
commit 23359c2d01
43 changed files with 4266 additions and 1139 deletions
+4
View File
@@ -24,6 +24,7 @@ import '../../features/calendar/ui/calendar_state_manager.dart';
import '../../features/friends/data/friends_api.dart';
import '../../features/messages/data/inbox_api.dart';
import '../../features/settings/data/settings_api.dart';
import '../../features/settings/data/services/automation_jobs_api.dart';
import '../../features/settings/data/services/settings_user_cache.dart';
import '../../features/settings/data/services/user_profile_cache_repository.dart';
import '../../features/settings/data/services/memory_service.dart';
@@ -112,6 +113,9 @@ Future<void> configureDependencies() async {
final settingsApi = SettingsApi(apiClient);
sl.registerSingleton<SettingsApi>(settingsApi);
final automationJobsApi = AutomationJobsApi(apiClient);
sl.registerSingleton<AutomationJobsApi>(automationJobsApi);
final memoryService = MemoryService(apiClient);
sl.registerSingleton<MemoryService>(memoryService);
+10
View File
@@ -23,6 +23,7 @@ import '../../features/todo/ui/screens/todo_detail_screen.dart';
import '../../features/todo/ui/screens/todo_edit_screen.dart';
import '../../features/settings/ui/screens/settings_screen.dart';
import '../../features/settings/ui/screens/features_screen.dart';
import '../../features/settings/ui/screens/job_detail_screen.dart';
import '../../features/settings/ui/screens/memory_screen.dart';
import '../../features/settings/ui/screens/user_memory_view_screen.dart';
import '../../features/settings/ui/screens/work_memory_view_screen.dart';
@@ -180,6 +181,15 @@ GoRouter createAppRouter(AuthBloc authBloc) {
path: AppRoutes.settingsFeatures,
builder: (context, state) => const FeaturesScreen(),
),
GoRoute(
path: AppRoutes.settingsJobNew,
builder: (context, state) => const JobDetailScreen(),
),
GoRoute(
path: '/settings/job/:id',
builder: (context, state) =>
JobDetailScreen(jobId: state.pathParameters['id']),
),
GoRoute(
path: AppRoutes.settingsMemory,
builder: (context, state) => const MemoryScreen(),
+2
View File
@@ -29,6 +29,8 @@ class AppRoutes {
static const settingsMain = '/settings';
static const settingsFeatures = '/settings/features';
static const settingsJobNew = '/settings/job/new';
static String settingsJobDetail(String id) => '/settings/job/$id';
static const settingsMemory = '/settings/memory';
static const settingsMemoryUser = '/settings/memory/user';
static const settingsMemoryWork = '/settings/memory/work';
@@ -1,110 +0,0 @@
import 'dart:convert';
enum Intent { createEvent, searchEvent, unknown }
/// 意图匹配规则(顺序敏感:searchEvent 优先级高于 createEvent
final _orderedPatterns = <(RegExp, Intent)>[
(RegExp(r'^查看|^有什么|^今天.*日程|^明天.*安排|^查询'), Intent.searchEvent),
(RegExp(r'提醒|开会|预约|安排.*时间|创建.*日程'), Intent.createEvent),
(RegExp(r'明天.*\d|今天.*\d|后天.*\d|\d{1,2}点|\d{1,2}:\d{2}'), Intent.createEvent),
];
/// 时区常量
const _defaultTimezone = 'Asia/Shanghai';
const _dayToday = '今天';
const _dayTomorrow = '明天';
const _dayAfterTomorrow = '后天';
const _tomorrowOffset = 1;
const _dayAfterTomorrowOffset = 2;
const _defaultMinute = 0;
class AiDecisionEngine {
Intent matchIntent(String text) {
for (final (pattern, intent) in _orderedPatterns) {
if (pattern.hasMatch(text)) return intent;
}
return Intent.unknown;
}
Map<String, dynamic>? tryExtractEventArgs(String text) {
if (matchIntent(text) != Intent.createEvent) return null;
final args = <String, dynamic>{};
final titleMatch = RegExp(r'提醒(.+?)(?:明天|今天|几点|$)').firstMatch(text);
if (titleMatch != null) {
args['title'] = titleMatch.group(1)?.trim() ?? text;
} else if (RegExp(r'\d{1,2}[:点]|\d{1,2}点').hasMatch(text)) {
args['title'] = text
.replaceAll(RegExp(r'\d{1,2}[:点]\d{0,2}|明天|今天|后天'), '')
.trim();
}
final title = args['title'];
if (title == null || (title as String).isEmpty) return null;
final timeMatch = RegExp(
r'(明天|今天|后天)?\s*(\d{1,2})[:点](\d{2})?',
).firstMatch(text);
if (timeMatch != null) {
final dayStr = timeMatch.group(1) ?? _dayToday;
final hour = int.parse(timeMatch.group(2)!);
final minute = int.parse(timeMatch.group(3) ?? '$_defaultMinute');
final now = DateTime.now();
final dayOffset = switch (dayStr) {
_dayTomorrow => _tomorrowOffset,
_dayAfterTomorrow => _dayAfterTomorrowOffset,
_ => 0,
};
final startAt = DateTime(
now.year,
now.month,
now.day + dayOffset,
hour,
minute,
);
args['startAt'] = startAt.toIso8601String();
args['timezone'] = _defaultTimezone;
}
if (!args.containsKey('startAt')) return null;
return args;
}
bool shouldTriggerToolCall(String text) =>
matchIntent(text) == Intent.createEvent;
Map<String, dynamic>? getToolCallArgs(String text) {
if (!shouldTriggerToolCall(text)) return null;
return tryExtractEventArgs(text);
}
ForceTriggerResult? tryForceTrigger(String text) {
final match = RegExp(
r'#tool:([A-Za-z0-9_.-]+)\s*(\{.*\})?',
).firstMatch(text);
if (match == null) return null;
final toolName = match.group(1)!;
final argsJson = match.group(2);
Map<String, dynamic>? args;
if (argsJson != null) {
try {
args = jsonDecode(argsJson) as Map<String, dynamic>;
} catch (_) {
args = {};
}
}
return ForceTriggerResult(toolName: toolName, args: args ?? {});
}
}
class ForceTriggerResult {
final String toolName;
final Map<String, dynamic> args;
ForceTriggerResult({required this.toolName, required this.args});
}
@@ -1,75 +0,0 @@
import '../../../../core/router/app_routes.dart';
typedef RouteNavigator = void Function(String target, {bool replace});
const Set<String> _allowedRoutes = {
AppRoutes.settingsMain,
AppRoutes.todoList,
AppRoutes.todoCreate,
AppRoutes.calendarDayWeek,
AppRoutes.calendarMonth,
AppRoutes.calendarEventCreate,
AppRoutes.messageInviteList,
AppRoutes.contactsList,
AppRoutes.contactsAdd,
};
const List<String> _allowedRoutePrefixes = [
'/calendar/events/',
'/todo/',
'/messages/invites/',
];
class RouteNavigationTool {
RouteNavigationTool._();
static final RouteNavigationTool instance = RouteNavigationTool._();
RouteNavigator? _navigator;
void bindNavigator(RouteNavigator navigator) {
_navigator = navigator;
}
void clearNavigator() {
_navigator = null;
}
Map<String, dynamic> execute(Map<String, dynamic> args) {
final target = args['target'];
if (target is! String || target.isEmpty) {
return {'ok': false, 'error': 'target is required'};
}
if (!_isAllowedTarget(target)) {
return {'ok': false, 'target': target, 'error': 'target is not allowed'};
}
final replace = args['replace'] == true;
final navigator = _navigator;
if (navigator == null) {
return {
'ok': false,
'target': target,
'replace': replace,
'error': 'navigator not bound',
};
}
navigator(target, replace: replace);
return {'ok': true, 'target': target, 'replace': replace, 'applied': true};
}
bool _isAllowedTarget(String target) {
if (!target.startsWith('/')) {
return false;
}
final normalized = target.split('?').first;
if (_allowedRoutes.contains(normalized)) {
return true;
}
for (final prefix in _allowedRoutePrefixes) {
if (normalized.startsWith(prefix)) {
return true;
}
}
return false;
}
}
@@ -1,106 +0,0 @@
import 'route_navigation_tool.dart';
typedef ToolHandler =
Future<Map<String, dynamic>> Function(Map<String, dynamic> args);
/// 工具常量
const _toolNameNavigateRoute = 'front.navigate_to_route';
class ToolDefinition {
final String name;
final String description;
final Map<String, dynamic> parameters;
final ToolHandler handler;
ToolDefinition({
required this.name,
required this.description,
required this.parameters,
required this.handler,
});
}
class ToolRegistry {
static final Map<String, ToolDefinition> _tools = {};
static bool _initialized = false;
static void initialize() {
if (_initialized) return;
_tools[_toolNameNavigateRoute] = ToolDefinition(
name: _toolNameNavigateRoute,
description: '在前端执行路由跳转',
parameters: {
'type': 'object',
'properties': {
'target': {'type': 'string', 'description': '跳转目标路由'},
'replace': {'type': 'boolean', 'description': '是否 replace 导航'},
},
'required': ['target'],
},
handler: _handleNavigateRoute,
);
_initialized = true;
}
static Future<Map<String, dynamic>> _handleNavigateRoute(
Map<String, dynamic> args,
) async {
return RouteNavigationTool.instance.execute(args);
}
static ToolDefinition? getTool(String name) => _tools[name];
static List<ToolDefinition> getAllTools() => _tools.values.toList();
static Future<Map<String, dynamic>> execute(
String toolName,
Map<String, dynamic> args,
) async {
final tool = _tools[toolName];
if (tool == null) throw ToolNotFoundException('Tool not found: $toolName');
return tool.handler(args);
}
static ToolValidationResult validateArgs(
String toolName,
Map<String, dynamic> args,
) {
final tool = _tools[toolName];
if (tool == null) {
return ToolValidationResult(
ok: false,
error: 'Tool not found: $toolName',
);
}
final required = tool.parameters['required'] as List<dynamic>? ?? [];
final missing = <String>[];
for (final field in required) {
if (!args.containsKey(field) || args[field] == null) {
missing.add(field as String);
}
}
if (missing.isNotEmpty) {
return ToolValidationResult(
ok: false,
error: 'Missing required fields: ${missing.join(', ')}',
);
}
return ToolValidationResult(ok: true);
}
}
class ToolNotFoundException implements Exception {
final String message;
ToolNotFoundException(this.message);
@override
String toString() => 'ToolNotFoundException: $message';
}
class ToolValidationResult {
final bool ok;
final String? error;
ToolValidationResult({required this.ok, this.error});
}
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/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';
@@ -248,7 +249,7 @@ class HomeChatItemRenderer {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.toolName,
localizeToolName(item.toolName),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
@@ -0,0 +1,417 @@
int _parseInt(dynamic v, {required String field, required int fallback}) {
if (v == null) {
return fallback;
}
if (v is int) {
return v;
}
if (v is String) {
return int.tryParse(v) ?? (throw FormatException('$field invalid'));
}
throw FormatException('$field invalid type');
}
List<String> _parseStringList(dynamic v, {required String field}) {
if (v == null) {
return const [];
}
if (v is List && v.every((e) => e is String)) {
return v.cast<String>();
}
throw FormatException('$field invalid type');
}
String _parseString(
dynamic v, {
required String field,
required String fallback,
}) {
if (v == null) {
return fallback;
}
if (v is String) {
return v;
}
throw FormatException('$field invalid type');
}
class MessageContextConfigModel {
final String source;
final String windowMode;
final int windowCount;
MessageContextConfigModel({
required this.source,
required this.windowMode,
required this.windowCount,
});
factory MessageContextConfigModel.fromJson(Map<String, dynamic>? json) {
if (json == null) {
return MessageContextConfigModel(
source: 'latest_chat',
windowMode: 'day',
windowCount: 2,
);
}
return MessageContextConfigModel(
source: _parseString(
json['source'],
field: 'source',
fallback: 'latest_chat',
),
windowMode: _parseString(
json['window_mode'],
field: 'window_mode',
fallback: 'day',
),
windowCount: _parseInt(
json['window_count'],
field: 'window_count',
fallback: 2,
),
);
}
Map<String, dynamic> toJson() => {
'source': source,
'window_mode': windowMode,
'window_count': windowCount,
};
MessageContextConfigModel copyWith({
String? source,
String? windowMode,
int? windowCount,
}) {
return MessageContextConfigModel(
source: source ?? this.source,
windowMode: windowMode ?? this.windowMode,
windowCount: windowCount ?? this.windowCount,
);
}
}
class AutomationJobConfigModel {
final String inputTemplate;
final List<String> enabledTools;
final MessageContextConfigModel context;
AutomationJobConfigModel({
required this.inputTemplate,
required this.enabledTools,
required this.context,
});
factory AutomationJobConfigModel.fromJson(Map<String, dynamic>? json) {
if (json == null) {
return AutomationJobConfigModel(
inputTemplate: '',
enabledTools: const [],
context: MessageContextConfigModel.fromJson(null),
);
}
return AutomationJobConfigModel(
inputTemplate: _parseString(
json['input_template'],
field: 'input_template',
fallback: '',
),
enabledTools: _parseStringList(
json['enabled_tools'],
field: 'enabled_tools',
),
context: json['context'] != null
? MessageContextConfigModel.fromJson(
json['context'] as Map<String, dynamic>?,
)
: MessageContextConfigModel.fromJson(null),
);
}
Map<String, dynamic> toJson() => {
'input_template': inputTemplate,
'enabled_tools': enabledTools,
'context': context.toJson(),
};
AutomationJobConfigModel copyWith({
String? inputTemplate,
List<String>? enabledTools,
MessageContextConfigModel? context,
}) {
return AutomationJobConfigModel(
inputTemplate: inputTemplate ?? this.inputTemplate,
enabledTools: enabledTools ?? this.enabledTools,
context: context ?? this.context,
);
}
}
class AutomationJobModel {
final String id;
final String ownerId;
final String? bootstrapKey;
final String title;
final String scheduleType;
final String runAt;
final String timezone;
final String status;
final bool isSystem;
final AutomationJobConfigModel config;
final DateTime nextRunAt;
final DateTime? lastRunAt;
final DateTime createdAt;
final DateTime updatedAt;
AutomationJobModel({
required this.id,
required this.ownerId,
this.bootstrapKey,
required this.title,
required this.scheduleType,
required this.runAt,
required this.timezone,
required this.status,
required this.isSystem,
required this.config,
required this.nextRunAt,
this.lastRunAt,
required this.createdAt,
required this.updatedAt,
});
factory AutomationJobModel.fromJson(Map<String, dynamic> json) {
return AutomationJobModel(
id: _parseString(json['id'], field: 'id', fallback: ''),
ownerId: _parseString(json['owner_id'], field: 'owner_id', fallback: ''),
bootstrapKey: json['bootstrap_key'] == null
? null
: _parseString(
json['bootstrap_key'],
field: 'bootstrap_key',
fallback: '',
),
title: _parseString(json['title'], field: 'title', fallback: ''),
scheduleType: _parseString(
json['schedule_type'],
field: 'schedule_type',
fallback: 'daily',
),
runAt: _parseString(
json['run_at'],
field: 'run_at',
fallback: '08:00:00',
),
timezone: _parseString(
json['timezone'],
field: 'timezone',
fallback: 'UTC',
),
status: _parseString(json['status'], field: 'status', fallback: 'active'),
isSystem: json['is_system'] == null
? false
: (json['is_system'] is bool
? json['is_system'] as bool
: throw FormatException('is_system invalid type')),
config: json['config'] != null
? AutomationJobConfigModel.fromJson(
json['config'] as Map<String, dynamic>?,
)
: AutomationJobConfigModel.fromJson(null),
nextRunAt: json['next_run_at'] != null
? DateTime.parse(
_parseString(
json['next_run_at'],
field: 'next_run_at',
fallback: '',
),
)
: throw FormatException('next_run_at is required'),
lastRunAt: json['last_run_at'] != null
? DateTime.parse(
_parseString(
json['last_run_at'],
field: 'last_run_at',
fallback: '',
),
)
: null,
createdAt: json['created_at'] != null
? DateTime.parse(
_parseString(
json['created_at'],
field: 'created_at',
fallback: '',
),
)
: throw FormatException('created_at is required'),
updatedAt: json['updated_at'] != null
? DateTime.parse(
_parseString(
json['updated_at'],
field: 'updated_at',
fallback: '',
),
)
: throw FormatException('updated_at is required'),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'owner_id': ownerId,
'bootstrap_key': bootstrapKey,
'title': title,
'schedule_type': scheduleType,
'run_at': runAt,
'timezone': timezone,
'status': status,
'is_system': isSystem,
'config': config.toJson(),
'next_run_at': nextRunAt.toIso8601String(),
'last_run_at': lastRunAt?.toIso8601String(),
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
AutomationJobModel copyWith({
String? id,
String? ownerId,
String? bootstrapKey,
String? title,
String? scheduleType,
String? runAt,
String? timezone,
String? status,
bool? isSystem,
AutomationJobConfigModel? config,
DateTime? nextRunAt,
DateTime? lastRunAt,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return AutomationJobModel(
id: id ?? this.id,
ownerId: ownerId ?? this.ownerId,
bootstrapKey: bootstrapKey ?? this.bootstrapKey,
title: title ?? this.title,
scheduleType: scheduleType ?? this.scheduleType,
runAt: runAt ?? this.runAt,
timezone: timezone ?? this.timezone,
status: status ?? this.status,
isSystem: isSystem ?? this.isSystem,
config: config ?? this.config,
nextRunAt: nextRunAt ?? this.nextRunAt,
lastRunAt: lastRunAt ?? this.lastRunAt,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
bool get isActive => status.toLowerCase() == 'active';
bool get isDaily => scheduleType.toLowerCase() == 'daily';
bool get isWeekly => scheduleType.toLowerCase() == 'weekly';
}
class AutomationJobListResponse {
final List<AutomationJobModel> items;
AutomationJobListResponse({required this.items});
factory AutomationJobListResponse.fromJson(Map<String, dynamic>? json) {
if (json == null) {
return AutomationJobListResponse(items: const []);
}
final itemsJson = json['items'];
if (itemsJson == null) {
return AutomationJobListResponse(items: const []);
}
if (itemsJson is List) {
return AutomationJobListResponse(
items: itemsJson
.map((e) => AutomationJobModel.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
throw FormatException('items invalid type');
}
}
class AutomationJobCreateRequest {
final String title;
final String scheduleType;
final String runAt;
final String timezone;
final String status;
final AutomationJobConfigModel config;
AutomationJobCreateRequest({
required this.title,
required this.scheduleType,
required this.runAt,
required this.timezone,
required this.status,
required this.config,
});
Map<String, dynamic> toJson() => {
'title': title,
'schedule_type': scheduleType,
'run_at': runAt,
'timezone': timezone,
'status': status,
'config': config.toJson(),
};
}
class AutomationJobConfigPatchModel {
final String? inputTemplate;
final List<String>? enabledTools;
final MessageContextConfigModel? context;
AutomationJobConfigPatchModel({
this.inputTemplate,
this.enabledTools,
this.context,
});
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
if (inputTemplate != null) map['input_template'] = inputTemplate;
if (enabledTools != null) map['enabled_tools'] = enabledTools;
if (context != null) map['context'] = context!.toJson();
return map;
}
}
class AutomationJobUpdateRequest {
final String? title;
final String? scheduleType;
final String? runAt;
final String? timezone;
final String? status;
final AutomationJobConfigPatchModel? config;
AutomationJobUpdateRequest({
this.title,
this.scheduleType,
this.runAt,
this.timezone,
this.status,
this.config,
});
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
if (title != null) map['title'] = title;
if (scheduleType != null) map['schedule_type'] = scheduleType;
if (runAt != null) map['run_at'] = runAt;
if (timezone != null) map['timezone'] = timezone;
if (status != null) map['status'] = status;
if (config != null) map['config'] = config!.toJson();
return map;
}
}
@@ -0,0 +1,38 @@
import 'package:social_app/core/api/i_api_client.dart';
import '../models/automation_job_model.dart';
class AutomationJobsApi {
final IApiClient _client;
static const _prefix = '/api/v1/automation-jobs';
AutomationJobsApi(this._client);
Future<List<AutomationJobModel>> list() async {
final response = await _client.get(_prefix);
final parsed = AutomationJobListResponse.fromJson(response.data);
return parsed.items;
}
Future<AutomationJobModel> get(String id) async {
final response = await _client.get('$_prefix/$id');
return AutomationJobModel.fromJson(response.data);
}
Future<AutomationJobModel> create(AutomationJobCreateRequest request) async {
final response = await _client.post(_prefix, data: request.toJson());
return AutomationJobModel.fromJson(response.data);
}
Future<AutomationJobModel> update(
String id,
AutomationJobUpdateRequest request,
) async {
final data = request.toJson();
final response = await _client.patch('$_prefix/$id', data: data);
return AutomationJobModel.fromJson(response.data);
}
Future<void> delete(String id) async {
await _client.delete('$_prefix/$id');
}
}
@@ -0,0 +1,84 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../data/models/automation_job_model.dart';
import '../../data/services/automation_jobs_api.dart';
class AutomationJobsState extends Equatable {
final List<AutomationJobModel> jobs;
final bool isLoading;
final String? error;
const AutomationJobsState({
this.jobs = const [],
this.isLoading = false,
this.error,
});
List<AutomationJobModel> get systemJobs =>
jobs.where((j) => j.isSystem).toList();
List<AutomationJobModel> get userJobs =>
jobs.where((j) => !j.isSystem).toList();
List<AutomationJobModel> get dailyJobs =>
jobs.where((j) => j.isDaily).toList();
List<AutomationJobModel> get weeklyJobs =>
jobs.where((j) => j.isWeekly).toList();
bool get canCreateMore => userJobs.length < 3;
AutomationJobsState copyWith({
List<AutomationJobModel>? jobs,
bool? isLoading,
String? error,
}) {
return AutomationJobsState(
jobs: jobs ?? this.jobs,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
@override
List<Object?> get props => [jobs, isLoading, error];
}
class AutomationJobsCubit extends Cubit<AutomationJobsState> {
final AutomationJobsApi _api;
AutomationJobsCubit(this._api) : super(AutomationJobsState());
Future<void> loadJobs() async {
emit(state.copyWith(isLoading: true));
try {
final jobs = await _api.list();
emit(state.copyWith(jobs: jobs, isLoading: false));
} catch (e) {
emit(state.copyWith(isLoading: false, error: e.toString()));
}
}
Future<void> deleteJob(String id) async {
try {
await _api.delete(id);
await loadJobs();
} catch (e) {
emit(state.copyWith(error: e.toString()));
}
}
Future<void> updateJobStatus({
required String id,
required bool enabled,
}) async {
try {
final updated = await _api.update(
id,
AutomationJobUpdateRequest(status: enabled ? 'active' : 'disabled'),
);
final nextJobs = state.jobs
.map((job) => job.id == id ? updated : job)
.toList();
emit(state.copyWith(jobs: nextJobs));
} catch (e) {
emit(state.copyWith(error: e.toString()));
}
}
}
@@ -0,0 +1,87 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../data/models/automation_job_model.dart';
import '../../data/services/automation_jobs_api.dart';
class JobDetailState extends Equatable {
final AutomationJobModel? job;
final bool isLoading;
final bool isSaving;
final String? error;
const JobDetailState({
this.job,
this.isLoading = false,
this.isSaving = false,
this.error,
});
JobDetailState copyWith({
AutomationJobModel? job,
bool? isLoading,
bool? isSaving,
String? error,
}) {
return JobDetailState(
job: job ?? this.job,
isLoading: isLoading ?? this.isLoading,
isSaving: isSaving ?? this.isSaving,
error: error,
);
}
@override
List<Object?> get props => [job, isLoading, isSaving, error];
}
class JobDetailCubit extends Cubit<JobDetailState> {
final AutomationJobsApi _api;
JobDetailCubit(this._api) : super(JobDetailState());
Future<void> loadJob(String id) async {
emit(state.copyWith(isLoading: true));
try {
final job = await _api.get(id);
emit(state.copyWith(job: job, isLoading: false));
} catch (e) {
emit(state.copyWith(isLoading: false, error: e.toString()));
}
}
Future<bool> updateJob(String id, AutomationJobUpdateRequest request) async {
emit(state.copyWith(isSaving: true));
try {
final job = await _api.update(id, request);
emit(state.copyWith(job: job, isSaving: false));
return true;
} catch (e) {
emit(state.copyWith(isSaving: false, error: e.toString()));
return false;
}
}
Future<bool> deleteJob(String id) async {
emit(state.copyWith(isSaving: true, error: null));
try {
await _api.delete(id);
emit(state.copyWith(isSaving: false));
return true;
} catch (e) {
emit(state.copyWith(isSaving: false, error: e.toString()));
return false;
}
}
Future<bool> createJob(AutomationJobCreateRequest request) async {
emit(state.copyWith(isSaving: true, error: null));
try {
final job = await _api.create(request);
emit(state.copyWith(job: job, isSaving: false));
return true;
} catch (e) {
emit(state.copyWith(isSaving: false, error: e.toString()));
return false;
}
}
}
@@ -1,7 +1,18 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/router/app_routes.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/app_pressable.dart';
import '../../../../shared/widgets/app_toggle_switch.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/models/automation_job_model.dart';
import '../../data/services/automation_jobs_api.dart';
import '../../presentation/cubits/automation_jobs_cubit.dart';
import '../widgets/settings_page_scaffold.dart';
class FeaturesScreen extends StatefulWidget {
@@ -12,27 +23,88 @@ class FeaturesScreen extends StatefulWidget {
}
class _FeaturesScreenState extends State<FeaturesScreen> {
bool _dailyReminderEnabled = true;
bool _dailySummaryEnabled = false;
bool _weeklyReportEnabled = true;
bool _weeklyDigestEnabled = false;
late final AutomationJobsCubit _cubit;
@override
void initState() {
super.initState();
_cubit = AutomationJobsCubit(sl<AutomationJobsApi>());
_cubit.loadJobs();
}
@override
void dispose() {
_cubit.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SettingsPageScaffold(
title: '周期计划',
onBack: () => context.pop(),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('每日'),
const SizedBox(height: 8),
_buildDailyList(),
const SizedBox(height: 16),
_buildSectionTitle('每周'),
const SizedBox(height: 8),
_buildWeeklyList(),
return BlocProvider.value(
value: _cubit,
child: SettingsPageScaffold(
title: '周期计划',
onBack: () => context.pop(),
body: BlocBuilder<AutomationJobsCubit, AutomationJobsState>(
builder: (context, state) {
if (state.isLoading) {
return const Center(child: AppLoadingIndicator());
}
if (state.error != null) {
return Center(child: Text(state.error!));
}
return _buildJobList(state);
},
),
),
);
}
Widget _buildJobList(AutomationJobsState state) {
final dailyJobs = state.dailyJobs;
final weeklyJobs = state.weeklyJobs;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('每日'),
const SizedBox(height: AppSpacing.sm),
if (dailyJobs.isEmpty)
_buildEmptyHint('暂无每日计划')
else
...dailyJobs.map(_buildJobCard),
const SizedBox(height: AppSpacing.lg),
_buildSectionTitle('每周'),
const SizedBox(height: AppSpacing.sm),
if (weeklyJobs.isEmpty)
_buildEmptyHint('暂无每周计划')
else
...weeklyJobs.map(_buildJobCard),
if (state.canCreateMore) ...[
const SizedBox(height: AppSpacing.lg),
_buildCreateButton(),
],
],
);
}
Widget _buildEmptyHint(String text) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderSecondary),
),
child: Text(
text,
style: const TextStyle(
color: AppColors.slate500,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
);
}
@@ -48,122 +120,95 @@ class _FeaturesScreenState extends State<FeaturesScreen> {
);
}
Widget _buildDailyList() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildFeatureCard(
icon: Icons.alarm,
iconColor: const Color(0xFF14B8A6),
iconBg: const Color(0xFFECFEFF),
iconBorder: const Color(0xFFC9F4F2),
title: '会议提醒',
subtitle: '每次会议前 15 分钟提醒',
value: _dailyReminderEnabled,
onChanged: (v) => setState(() => _dailyReminderEnabled = v),
Widget _buildJobCard(AutomationJobModel job) {
return AppPressable(
onTap: () async {
await context.push(AppRoutes.settingsJobDetail(job.id));
if (!mounted) {
return;
}
_cubit.loadJobs();
},
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Container(
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderSecondary),
),
const SizedBox(height: 10),
_buildFeatureCard(
icon: Icons.summarize,
iconColor: const Color(0xFF2563EB),
iconBg: const Color(0xFFEEF6FF),
iconBorder: const Color(0xFFDCEAFF),
title: '每日摘要',
subtitle: '每天 18:00 发送当日摘要',
value: _dailySummaryEnabled,
onChanged: (v) => setState(() => _dailySummaryEnabled = v),
child: Row(
children: [
Container(
width: AppSpacing.xxl + AppSpacing.lg,
height: AppSpacing.xxl + AppSpacing.lg,
decoration: BoxDecoration(
color: AppColors.surfaceTertiary,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: const Icon(
Icons.auto_awesome,
size: AppSpacing.lg,
color: AppColors.blue500,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
job.title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
_buildSubtitle(job),
style: const TextStyle(
fontSize: 12,
color: AppColors.slate500,
),
),
],
),
),
const SizedBox(width: AppSpacing.sm),
AppToggleSwitch(
value: job.isActive,
onChanged: (next) {
if (job.isSystem) {
Toast.show(context, '系统预置任务状态不可修改', type: ToastType.info);
return;
}
_cubit.updateJobStatus(id: job.id, enabled: next);
},
),
],
),
],
),
);
}
Widget _buildWeeklyList() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildFeatureCard(
icon: Icons.calendar_view_week,
iconColor: AppColors.success,
iconBg: const Color(0xFFECFDF5),
iconBorder: const Color(0xFFCDEEDC),
title: '周报生成',
subtitle: '每周一自动生成周报',
value: _weeklyReportEnabled,
onChanged: (v) => setState(() => _weeklyReportEnabled = v),
),
const SizedBox(height: 10),
_buildFeatureCard(
icon: Icons.article,
iconColor: AppColors.warning,
iconBg: const Color(0xFFFFF7ED),
iconBorder: const Color(0xFFFDE6CD),
title: '每周摘要',
subtitle: '每周日发送本周活动汇总',
value: _weeklyDigestEnabled,
onChanged: (v) => setState(() => _weeklyDigestEnabled = v),
),
],
);
String _buildSubtitle(AutomationJobModel job) {
final statusText = job.isActive ? '已启用' : '未启用';
final sourceText = job.isSystem ? '系统预置' : '自定义';
return '$sourceText$statusText';
}
Widget _buildFeatureCard({
required IconData icon,
required Color iconColor,
required Color iconBg,
required Color iconBorder,
required String title,
required String subtitle,
required bool value,
required ValueChanged<bool> onChanged,
}) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.borderSecondary),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconBg,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: iconBorder),
),
child: Icon(icon, size: 18, color: iconColor),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
color: AppColors.slate500,
),
),
],
),
),
AppToggleSwitch(value: value, onChanged: onChanged),
],
),
Widget _buildCreateButton() {
return AppButton(
text: '创建任务',
onPressed: () async {
await context.push(AppRoutes.settingsJobNew);
if (!mounted) {
return;
}
_cubit.loadJobs();
},
);
}
}
@@ -0,0 +1,783 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_input.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/app_pressable.dart';
import '../../../../shared/widgets/app_selection_sheet.dart';
import '../../../../shared/widgets/detail_header_action_menu.dart';
import '../../../../shared/widgets/destructive_action_sheet.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../../shared/utils/tool_name_localizer.dart';
import '../../data/models/automation_job_model.dart';
import '../../data/services/automation_jobs_api.dart';
import '../../presentation/cubits/job_detail_cubit.dart';
import '../widgets/settings_page_scaffold.dart';
class JobDetailScreen extends StatefulWidget {
const JobDetailScreen({super.key, this.jobId});
final String? jobId;
@override
State<JobDetailScreen> createState() => _JobDetailScreenState();
}
enum _JobDetailHeaderAction { delete }
class _JobDetailScreenState extends State<JobDetailScreen> {
late final JobDetailCubit _cubit;
final TextEditingController _titleController = TextEditingController();
final TextEditingController _templateController = TextEditingController();
String _scheduleType = 'daily';
String _timezone = 'Asia/Shanghai';
TimeOfDay _runAt = const TimeOfDay(hour: 8, minute: 0);
String _contextSource = 'latest_chat';
String _contextWindowMode = 'day';
int _contextWindowCount = 2;
final Set<String> _selectedTools = <String>{'memory.write', 'memory.forget'};
@override
void initState() {
super.initState();
_cubit = JobDetailCubit(sl<AutomationJobsApi>());
if (widget.jobId != null) {
_cubit.loadJob(widget.jobId!);
}
}
@override
void dispose() {
_titleController.dispose();
_templateController.dispose();
_cubit.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cubit,
child: BlocConsumer<JobDetailCubit, JobDetailState>(
listener: (context, state) {
if (state.error != null) {
Toast.show(context, state.error!, type: ToastType.error);
}
},
builder: (context, state) {
if (state.isLoading) {
return SettingsPageScaffold(
title: '加载中',
body: const Center(child: AppLoadingIndicator()),
);
}
final job = state.job;
final isEditMode = widget.jobId != null;
if (isEditMode && job == null && state.error != null) {
return SettingsPageScaffold(
title: '任务详情',
onBack: () => context.pop(),
body: _buildLoadFailedView(state.error!),
);
}
return SettingsPageScaffold(
title: job?.title ?? '新建周期计划',
onBack: () => context.pop(),
trailing: job != null && !job.isSystem
? _buildHeaderActions(job.id, state)
: null,
body: job == null
? _buildCreateForm(state)
: _buildDetailPage(job, state),
);
},
),
);
}
Widget _buildLoadFailedView(String error) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('加载失败'),
const SizedBox(height: AppSpacing.sm),
Text(
error,
style: const TextStyle(
color: AppColors.error,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: AppSpacing.md),
AppButton(text: '重试', onPressed: () => _cubit.loadJob(widget.jobId!)),
],
);
}
Widget _buildDetailPage(AutomationJobModel job, JobDetailState state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildOverviewCard(job),
const SizedBox(height: AppSpacing.lg),
_buildSectionTitle('计划配置'),
const SizedBox(height: AppSpacing.sm),
_buildInfoCard([
_buildInfoRow('周期', _scheduleLabel(job.scheduleType)),
_buildInfoRow('执行时间', _displayRunAt(job.runAt)),
_buildInfoRow('时区', job.timezone),
_buildInfoRow('状态', job.isActive ? '已启用' : '未启用'),
]),
const SizedBox(height: AppSpacing.lg),
_buildSectionTitle('输入模板'),
const SizedBox(height: AppSpacing.sm),
_buildTextBlock(job.config.inputTemplate),
const SizedBox(height: AppSpacing.lg),
_buildSectionTitle('启用工具'),
const SizedBox(height: AppSpacing.sm),
_buildToolWrap(job.config.enabledTools),
const SizedBox(height: AppSpacing.lg),
_buildSectionTitle('上下文消息模式'),
const SizedBox(height: AppSpacing.sm),
_buildInfoCard([
_buildInfoRow('来源', _contextSourceLabel(job.config.context.source)),
_buildInfoRow(
'窗口模式',
_windowModeLabel(job.config.context.windowMode),
),
_buildInfoRow('窗口数量', '${job.config.context.windowCount}'),
]),
if (!job.isSystem && state.isSaving)
const Padding(
padding: EdgeInsets.only(top: AppSpacing.lg),
child: Center(child: AppLoadingIndicator(size: AppSpacing.lg)),
),
],
);
}
Widget _buildHeaderActions(String jobId, JobDetailState state) {
return DetailHeaderActionMenu<_JobDetailHeaderAction>(
items: const [
DetailHeaderActionItem<_JobDetailHeaderAction>(
value: _JobDetailHeaderAction.delete,
label: '删除',
icon: Icons.delete_outline,
isDestructive: true,
),
],
onSelected: (action) {
if (state.isSaving) {
return;
}
if (action == _JobDetailHeaderAction.delete) {
unawaited(_confirmAndDelete(jobId));
}
},
);
}
Future<void> _confirmAndDelete(String jobId) async {
final confirmed = await showDestructiveActionSheet(
context,
title: '删除周期计划',
message: '删除后将无法恢复,是否继续?',
confirmText: '确认删除',
);
if (!confirmed) {
return;
}
final success = await _cubit.deleteJob(jobId);
if (!mounted) {
return;
}
if (success) {
Toast.show(context, '删除成功', type: ToastType.success);
context.pop();
}
}
Widget _buildOverviewCard(AutomationJobModel job) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.white, AppColors.surfaceInfoLight],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.borderTertiary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
job.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
),
const SizedBox(height: AppSpacing.sm),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
_buildBadge(job.isSystem ? '系统预置' : '自定义'),
_buildBadge(job.isActive ? '已启用' : '未启用'),
_buildBadge(_scheduleLabel(job.scheduleType)),
],
),
],
),
);
}
Widget _buildBadge(String text) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: AppColors.borderSecondary),
),
child: Text(
text,
style: const TextStyle(
color: AppColors.slate600,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
Widget _buildCreateForm(JobDetailState state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCreateBasicSection(),
const SizedBox(height: AppSpacing.lg),
_buildCreateRuleSection(),
const SizedBox(height: AppSpacing.lg),
_buildCreateToolSection(),
const SizedBox(height: AppSpacing.lg),
_buildCreateContextSection(),
const SizedBox(height: AppSpacing.xl),
AppButton(
text: '创建任务',
isLoading: state.isSaving,
onPressed: state.isSaving ? null : _submitCreate,
),
],
);
}
Widget _buildCreateBasicSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('基本信息'),
const SizedBox(height: AppSpacing.sm),
AppInput(label: '任务名称', hint: '请输入任务名称', controller: _titleController),
const SizedBox(height: AppSpacing.md),
AppInput(
label: '输入模板',
hint: '例如:请总结今天的记忆内容',
controller: _templateController,
maxLines: 4,
),
],
);
}
Widget _buildCreateRuleSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('执行规则'),
const SizedBox(height: AppSpacing.sm),
_buildPickerTile(
label: '周期',
value: _scheduleLabel(_scheduleType),
onTap: _pickScheduleType,
),
const SizedBox(height: AppSpacing.sm),
_buildPickerTile(
label: '执行时间',
value: _formatTime(_runAt),
onTap: _pickRunAt,
),
const SizedBox(height: AppSpacing.sm),
_buildPickerTile(label: '时区', value: _timezone, onTap: _pickTimezone),
],
);
}
Widget _buildCreateToolSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('工具选择'),
const SizedBox(height: AppSpacing.sm),
_buildToolSelector(),
],
);
}
Widget _buildCreateContextSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('上下文消息模式'),
const SizedBox(height: AppSpacing.sm),
_buildPickerTile(
label: '来源',
value: _contextSourceLabel(_contextSource),
onTap: _pickContextSource,
),
const SizedBox(height: AppSpacing.sm),
_buildPickerTile(
label: '窗口模式',
value: _windowModeLabel(_contextWindowMode),
onTap: _pickWindowMode,
),
const SizedBox(height: AppSpacing.sm),
_buildCounterTile(
label: '窗口数量',
value: _contextWindowCount,
onMinus: _contextWindowCount > 1
? () {
setState(() {
_contextWindowCount -= 1;
});
}
: null,
onPlus: _contextWindowCount < 200
? () {
setState(() {
_contextWindowCount += 1;
});
}
: null,
),
],
);
}
Widget _buildPickerTile({
required String label,
required String value,
required VoidCallback onTap,
}) {
return AppPressable(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.md,
),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderSecondary),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
color: AppColors.slate500,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
value,
style: const TextStyle(
color: AppColors.slate800,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
),
),
const Icon(Icons.keyboard_arrow_down, color: AppColors.slate400),
],
),
),
);
}
Widget _buildCounterTile({
required String label,
required int value,
required VoidCallback? onMinus,
required VoidCallback? onPlus,
}) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.md,
),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderSecondary),
),
child: Row(
children: [
Expanded(
child: Text(
'$label$value',
style: const TextStyle(
color: AppColors.slate800,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
_buildCounterAction(icon: Icons.remove, onTap: onMinus),
const SizedBox(width: AppSpacing.sm),
_buildCounterAction(icon: Icons.add, onTap: onPlus),
],
),
);
}
Widget _buildCounterAction({
required IconData icon,
required VoidCallback? onTap,
}) {
return AppPressable(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.full),
child: Container(
width: AppSpacing.xxl + AppSpacing.md,
height: AppSpacing.xxl + AppSpacing.md,
decoration: BoxDecoration(
color: onTap == null ? AppColors.slate100 : AppColors.surfaceTertiary,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: AppColors.borderSecondary),
),
child: Icon(
icon,
size: AppSpacing.lg,
color: onTap == null ? AppColors.slate300 : AppColors.blue500,
),
),
);
}
Widget _buildToolSelector() {
return Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: automationToolOptions.map((toolName) {
final selected = _selectedTools.contains(toolName);
return AppPressable(
onTap: () {
setState(() {
if (selected) {
_selectedTools.remove(toolName);
} else {
_selectedTools.add(toolName);
}
});
},
borderRadius: BorderRadius.circular(AppRadius.full),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: selected ? AppColors.blue50 : AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(
color: selected ? AppColors.blue300 : AppColors.borderSecondary,
),
),
child: Text(
localizeToolName(toolName),
style: TextStyle(
color: selected ? AppColors.blue600 : AppColors.slate600,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
);
}).toList(),
);
}
Widget _buildToolWrap(List<String> tools) {
if (tools.isEmpty) {
return _buildTextBlock('未启用工具');
}
return Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: tools
.map((tool) => _buildBadge(localizeToolName(tool)))
.toList(),
);
}
Widget _buildTextBlock(String text) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderSecondary),
),
child: Text(
text,
style: const TextStyle(
color: AppColors.slate700,
fontSize: 13,
fontWeight: FontWeight.w500,
height: 1.5,
),
),
);
}
Widget _buildSectionTitle(String title) {
return Text(
title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.slate500,
),
);
}
Widget _buildInfoCard(List<Widget> children) {
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderSecondary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: const TextStyle(
color: AppColors.slate500,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Text(
value,
textAlign: TextAlign.right,
style: const TextStyle(
color: AppColors.slate800,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
Future<void> _pickScheduleType() async {
final picked = await showAppSelectionSheet<String>(
context,
title: '选择周期',
selectedValue: _scheduleType,
items: const [
AppSelectionItem(value: 'daily', label: '每日'),
AppSelectionItem(value: 'weekly', label: '每周'),
],
);
if (picked != null) {
setState(() {
_scheduleType = picked;
});
}
}
Future<void> _pickTimezone() async {
final picked = await showAppSelectionSheet<String>(
context,
title: '选择时区',
selectedValue: _timezone,
items: const [
AppSelectionItem(value: 'Asia/Shanghai', label: 'Asia/Shanghai'),
AppSelectionItem(value: 'UTC', label: 'UTC'),
],
);
if (picked != null) {
setState(() {
_timezone = picked;
});
}
}
Future<void> _pickContextSource() async {
final picked = await showAppSelectionSheet<String>(
context,
title: '选择上下文来源',
selectedValue: _contextSource,
items: const [AppSelectionItem(value: 'latest_chat', label: '最近聊天')],
);
if (picked != null) {
setState(() {
_contextSource = picked;
});
}
}
Future<void> _pickWindowMode() async {
final picked = await showAppSelectionSheet<String>(
context,
title: '选择窗口模式',
selectedValue: _contextWindowMode,
items: const [
AppSelectionItem(value: 'day', label: '按天数'),
AppSelectionItem(value: 'number', label: '按消息数'),
],
);
if (picked != null) {
setState(() {
_contextWindowMode = picked;
});
}
}
Future<void> _pickRunAt() async {
final picked = await showTimePicker(context: context, initialTime: _runAt);
if (picked != null) {
setState(() {
_runAt = picked;
});
}
}
String _formatTime(TimeOfDay time) {
final hour = time.hour.toString().padLeft(2, '0');
final minute = time.minute.toString().padLeft(2, '0');
return '$hour:$minute:00';
}
String _displayRunAt(String runAtRaw) {
try {
final dt = DateTime.parse(runAtRaw).toLocal();
final hour = dt.hour.toString().padLeft(2, '0');
final minute = dt.minute.toString().padLeft(2, '0');
return '$hour:$minute';
} catch (_) {
return runAtRaw;
}
}
String _scheduleLabel(String scheduleType) {
final normalized = scheduleType.toLowerCase();
if (normalized == 'daily') {
return '每日';
}
if (normalized == 'weekly') {
return '每周';
}
return scheduleType;
}
String _contextSourceLabel(String source) {
if (source == 'latest_chat') {
return '最近聊天';
}
return source;
}
String _windowModeLabel(String mode) {
if (mode == 'day') {
return '按天数';
}
if (mode == 'number') {
return '按消息数';
}
return mode;
}
Future<void> _submitCreate() async {
final title = _titleController.text.trim();
final template = _templateController.text.trim();
if (title.isEmpty || template.isEmpty) {
Toast.show(context, '请填写完整信息', type: ToastType.error);
return;
}
final request = AutomationJobCreateRequest(
title: title,
scheduleType: _scheduleType,
runAt: _formatTime(_runAt),
timezone: _timezone,
status: 'active',
config: AutomationJobConfigModel(
inputTemplate: template,
enabledTools: _selectedTools.toList(),
context: MessageContextConfigModel(
source: _contextSource,
windowMode: _contextWindowMode,
windowCount: _contextWindowCount,
),
),
);
final success = await _cubit.createJob(request);
if (!mounted) {
return;
}
if (success) {
Toast.show(context, '创建成功', type: ToastType.success);
context.pop(true);
}
}
}
@@ -17,6 +17,7 @@ import 'package:social_app/features/auth/presentation/bloc/auth_event.dart';
import 'package:social_app/features/auth/presentation/bloc/auth_state.dart';
import 'package:social_app/features/friends/data/friends_api.dart';
import 'package:social_app/features/settings/data/settings_api.dart';
import 'package:social_app/features/settings/data/services/automation_jobs_api.dart';
import 'package:social_app/features/settings/data/services/settings_user_cache.dart';
import 'package:social_app/features/users/data/models/user_response.dart';
import 'package:social_app/features/home/ui/navigation/home_return_policy.dart';
@@ -34,12 +35,15 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
final FriendsApi _friendsApi = sl<FriendsApi>();
final AutomationJobsApi _automationJobsApi = sl<AutomationJobsApi>();
final SettingsUserCache _userCache = sl<SettingsUserCache>();
UserResponse? _user;
bool _isLoading = true;
int _friendsCount = 0;
String? _firstFriendName;
int _enabledJobsCount = 0;
String? _firstEnabledJobTitle;
@override
void initState() {
@@ -83,6 +87,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
} catch (e) {
// Keep profile available even when contacts fail.
}
try {
final jobs = await _automationJobsApi.list();
final enabledJobs = jobs.where((job) => job.isActive).toList();
if (mounted) {
setState(() {
_enabledJobsCount = enabledJobs.length;
_firstEnabledJobTitle = enabledJobs.isNotEmpty
? enabledJobs.first.title
: null;
});
}
} catch (e) {
// Keep profile available even when automation jobs fail.
}
}
@override
@@ -298,6 +317,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
return '已添加 $_friendsCount 位联系人';
}
String _buildAutomationSubtitle() {
if (_enabledJobsCount == 0) {
return '暂无启用计划';
}
if (_enabledJobsCount == 1) {
return '已启用:${_firstEnabledJobTitle ?? '周期计划'}';
}
return '已启用 $_enabledJobsCount 个计划';
}
Widget _buildQuickActions(BuildContext context) {
return Row(
children: [
@@ -314,9 +343,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
Expanded(
child: _buildActionCard(
icon: Icons.auto_awesome,
iconColor: AppColors.violet500,
iconColor: AppColors.blue500,
title: '周期计划',
subtitle: '已启用:会议提醒',
subtitle: _buildAutomationSubtitle(),
onTap: () => context.push(AppRoutes.settingsFeatures),
),
),
@@ -0,0 +1,35 @@
const Map<String, String> _toolNameZhMap = {
'calendar.read': '读取日程',
'calendar.write': '写入日程',
'calendar.share': '共享日程',
'user.lookup': '查找联系人',
'memory.write': '写入记忆',
'memory.forget': '清理记忆',
};
const Map<String, String> _toolNameAliases = {
'calendar_read': 'calendar.read',
'calendar_write': 'calendar.write',
'calendar_share': 'calendar.share',
'user_lookup': 'user.lookup',
'memory_write': 'memory.write',
'memory_forget': 'memory.forget',
};
const List<String> automationToolOptions = [
'calendar.read',
'calendar.write',
'calendar.share',
'user.lookup',
'memory.write',
'memory.forget',
];
String localizeToolName(String rawName) {
final normalized = rawName.trim().toLowerCase();
if (normalized.isEmpty) {
return rawName;
}
final canonical = _toolNameAliases[normalized] ?? normalized;
return _toolNameZhMap[canonical] ?? rawName;
}
+29 -26
View File
@@ -6,7 +6,7 @@ class AppToggleSwitch extends StatelessWidget {
const AppToggleSwitch({
super.key,
required this.value,
required this.onChanged,
this.onChanged,
this.activeBackgroundColor,
this.inactiveBackgroundColor,
this.activeBorderColor,
@@ -14,7 +14,7 @@ class AppToggleSwitch extends StatelessWidget {
});
final bool value;
final ValueChanged<bool> onChanged;
final ValueChanged<bool>? onChanged;
final Color? activeBackgroundColor;
final Color? inactiveBackgroundColor;
final Color? activeBorderColor;
@@ -23,32 +23,35 @@ class AppToggleSwitch extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onChanged(!value),
child: Container(
width: AppSpacing.xxl + AppSpacing.xl,
height: AppSpacing.xl + AppSpacing.xs,
padding: const EdgeInsets.all(AppSpacing.xs / 2),
decoration: BoxDecoration(
color: value
? (activeBackgroundColor ?? AppColors.blue100)
: (inactiveBackgroundColor ?? AppColors.surfaceTertiary),
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(
onTap: onChanged == null ? null : () => onChanged!(!value),
child: Opacity(
opacity: onChanged == null ? 0.55 : 1,
child: Container(
width: AppSpacing.xxl + AppSpacing.xl,
height: AppSpacing.xl + AppSpacing.xs,
padding: const EdgeInsets.all(AppSpacing.xs / 2),
decoration: BoxDecoration(
color: value
? (activeBorderColor ?? AppColors.blue300)
: (inactiveBorderColor ?? AppColors.borderSecondary),
? (activeBackgroundColor ?? AppColors.blue100)
: (inactiveBackgroundColor ?? AppColors.surfaceTertiary),
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(
color: value
? (activeBorderColor ?? AppColors.blue300)
: (inactiveBorderColor ?? AppColors.borderSecondary),
),
),
),
child: AnimatedAlign(
duration: const Duration(milliseconds: 150),
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
width: AppSpacing.lg + AppSpacing.xs,
height: AppSpacing.lg + AppSpacing.xs,
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: AppColors.borderSecondary),
child: AnimatedAlign(
duration: const Duration(milliseconds: 150),
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
width: AppSpacing.lg + AppSpacing.xs,
height: AppSpacing.lg + AppSpacing.xs,
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: AppColors.borderSecondary),
),
),
),
),
@@ -1,173 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/chat/data/ai/ai_decision_engine.dart';
void main() {
late AiDecisionEngine engine;
setUp(() {
engine = AiDecisionEngine();
});
group('matchIntent', () {
test('returns searchEvent for "今天有什么日程"', () {
expect(engine.matchIntent('今天有什么日程'), Intent.searchEvent);
});
test('returns searchEvent for "查看日程"', () {
expect(engine.matchIntent('查看日程'), Intent.searchEvent);
});
test('returns searchEvent for "查询安排"', () {
expect(engine.matchIntent('查询安排'), Intent.searchEvent);
});
test('returns createEvent for "提醒我明天开会"', () {
expect(engine.matchIntent('提醒我明天开会'), Intent.createEvent);
});
test('returns createEvent for "安排时间"', () {
expect(engine.matchIntent('安排时间'), Intent.createEvent);
});
test('returns createEvent for time pattern "明天10点"', () {
expect(engine.matchIntent('明天10点'), Intent.createEvent);
});
test('returns unknown for "你好"', () {
expect(engine.matchIntent('你好'), Intent.unknown);
});
test('returns unknown for random text', () {
expect(engine.matchIntent('随便说点什么'), Intent.unknown);
});
});
group('shouldTriggerToolCall', () {
test('returns false for "你好"', () {
expect(engine.shouldTriggerToolCall('你好'), false);
});
test('returns false for search intent', () {
expect(engine.shouldTriggerToolCall('今天有什么日程'), false);
});
test('returns true for create event intent', () {
expect(engine.shouldTriggerToolCall('提醒我明天开会'), true);
});
test('returns true for time pattern', () {
expect(engine.shouldTriggerToolCall('明天10点开会'), true);
});
});
group('tryExtractEventArgs', () {
test('returns map with title and startAt for "提醒我明天10点开会"', () {
final result = engine.tryExtractEventArgs('提醒我明天10点开会');
expect(result, isNotNull);
expect(result!['title'], isNotNull);
expect(result['startAt'], isNotNull);
expect(result['timezone'], 'Asia/Shanghai');
});
test('returns null for "你好"', () {
expect(engine.tryExtractEventArgs('你好'), isNull);
});
test('returns null for search intent', () {
expect(engine.tryExtractEventArgs('今天有什么日程'), isNull);
});
test('extracts title correctly', () {
final result = engine.tryExtractEventArgs('提醒我开会明天10点');
expect(result, isNotNull);
expect(result!['title'], contains('开会'));
});
test('parses today time correctly', () {
final result = engine.tryExtractEventArgs('开会今天14:30');
final now = DateTime.now();
expect(result, isNotNull);
final startAt = DateTime.parse(result!['startAt'] as String);
expect(startAt.year, now.year);
expect(startAt.month, now.month);
expect(startAt.day, now.day);
expect(startAt.hour, 14);
expect(startAt.minute, 30);
});
test('parses tomorrow time correctly', () {
final result = engine.tryExtractEventArgs('开会明天9点');
final now = DateTime.now();
final expectedTomorrow = DateTime(now.year, now.month, now.day + 1);
expect(result, isNotNull);
final startAt = DateTime.parse(result!['startAt'] as String);
expect(startAt.day, equals(expectedTomorrow.day));
expect(startAt.hour, 9);
expect(startAt.minute, 0);
});
});
group('tryForceTrigger', () {
test(
'returns ForceTriggerResult for "#tool:front.navigate_to_route {}"',
() {
final result = engine.tryForceTrigger(
'#tool:front.navigate_to_route {}',
);
expect(result, isNotNull);
expect(result!.toolName, 'front.navigate_to_route');
expect(result.args, isEmpty);
},
);
test(
'returns ForceTriggerResult with args for "#tool:custom {"key": "value"}"',
() {
final result = engine.tryForceTrigger('#tool:custom {"key": "value"}');
expect(result, isNotNull);
expect(result!.toolName, 'custom');
expect(result.args['key'], 'value');
},
);
test('returns null for normal text', () {
expect(engine.tryForceTrigger('普通文本'), isNull);
});
test('returns null for empty string', () {
expect(engine.tryForceTrigger(''), isNull);
});
test('handles invalid JSON gracefully', () {
final result = engine.tryForceTrigger('#tool:test {invalid json}');
expect(result, isNotNull);
expect(result!.toolName, 'test');
expect(result.args, isEmpty);
});
});
group('getToolCallArgs', () {
test('returns args for create event intent', () {
final result = engine.getToolCallArgs('提醒我明天10点开会');
expect(result, isNotNull);
expect(result!['title'], isNotNull);
expect(result['startAt'], isNotNull);
});
test('returns null for non-create intent', () {
expect(engine.getToolCallArgs('你好'), isNull);
});
test('returns null for search intent', () {
expect(engine.getToolCallArgs('今天有什么日程'), isNull);
});
});
}
@@ -1,101 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/chat/data/tools/route_navigation_tool.dart';
import 'package:social_app/features/chat/data/tools/tool_registry.dart';
void main() {
setUp(() {
ToolRegistry.initialize();
});
tearDown(() {
RouteNavigationTool.instance.clearNavigator();
});
group('getTool', () {
test('returns tool definition for front.navigate_to_route', () {
final tool = ToolRegistry.getTool('front.navigate_to_route');
expect(tool, isNotNull);
expect(tool!.name, 'front.navigate_to_route');
expect(tool.description, isNotEmpty);
});
test('returns null for unknown tool', () {
expect(ToolRegistry.getTool('unknown_tool'), isNull);
});
});
group('validateArgs', () {
test('returns error for empty args (missing target)', () {
final result = ToolRegistry.validateArgs('front.navigate_to_route', {});
expect(result.ok, false);
expect(result.error, contains('target'));
});
test('returns ok: true for valid args', () {
final result = ToolRegistry.validateArgs('front.navigate_to_route', {
'target': '/settings',
});
expect(result.ok, true);
expect(result.error, isNull);
});
test('returns error for unknown tool', () {
final result = ToolRegistry.validateArgs('unknown_tool', {});
expect(result.ok, false);
expect(result.error, contains('Tool not found'));
});
});
group('execute', () {
test('throws ToolNotFoundException for unknown tool', () async {
expect(
() => ToolRegistry.execute('unknown_tool', {}),
throwsA(isA<ToolNotFoundException>()),
);
});
test('front.navigate_to_route rejects disallowed target', () async {
final result = await ToolRegistry.execute('front.navigate_to_route', {
'target': '/admin',
});
expect(result['ok'], false);
expect(result['error'], contains('not allowed'));
});
test(
'front.navigate_to_route executes allowed target when navigator is bound',
() async {
String? navigatedTo;
bool replaced = false;
RouteNavigationTool.instance.bindNavigator((target, {replace = false}) {
navigatedTo = target;
replaced = replace;
});
final result = await ToolRegistry.execute('front.navigate_to_route', {
'target': '/settings',
'replace': true,
});
expect(result['ok'], true);
expect(navigatedTo, '/settings');
expect(replaced, true);
},
);
});
group('getAllTools', () {
test('returns list of tool definitions', () {
final tools = ToolRegistry.getAllTools();
expect(tools, isNotEmpty);
expect(tools.any((t) => t.name == 'front.navigate_to_route'), true);
expect(tools.any((t) => t.name == 'create_calendar_event'), false);
});
});
}
@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/chat/data/models/chat_list_item.dart';
import 'package:social_app/features/home/ui/widgets/home_chat_item_renderer.dart';
void main() {
ToolCallItem _toolCallItem(String toolName) {
return ToolCallItem(
id: 'tc-1',
callId: 'tc-1',
toolName: toolName,
args: const {},
status: ToolCallStatus.pending,
timestamp: DateTime(2026, 1, 1),
sender: MessageSender.ai,
);
}
Future<void> _pumpToolCallItem(WidgetTester tester, String toolName) async {
final widget = MaterialApp(
home: Scaffold(body: HomeChatItemRenderer.build(_toolCallItem(toolName))),
);
await tester.pumpWidget(widget);
}
group('HomeChatItemRenderer tool name localization', () {
testWidgets('renders dot style name in Chinese', (tester) async {
await _pumpToolCallItem(tester, 'memory.write');
expect(find.text('写入记忆'), findsOneWidget);
});
testWidgets('renders snake style alias in Chinese', (tester) async {
await _pumpToolCallItem(tester, 'memory_write');
expect(find.text('写入记忆'), findsOneWidget);
});
testWidgets('falls back to raw name for unknown tool', (tester) async {
await _pumpToolCallItem(tester, 'unknown.tool');
expect(find.text('unknown.tool'), findsOneWidget);
});
});
}
@@ -0,0 +1,297 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/settings/data/models/automation_job_model.dart';
void main() {
group('MessageContextConfigModel', () {
test('fromJson parses all fields correctly', () {
final json = {
'source': 'messages',
'window_mode': 'week',
'window_count': 5,
};
final model = MessageContextConfigModel.fromJson(json);
expect(model.source, 'messages');
expect(model.windowMode, 'week');
expect(model.windowCount, 5);
});
test('fromJson uses defaults for missing fields', () {
final model = MessageContextConfigModel.fromJson(null);
expect(model.source, 'latest_chat');
expect(model.windowMode, 'day');
expect(model.windowCount, 2);
});
test('toJson serializes correctly', () {
final model = MessageContextConfigModel(
source: 'messages',
windowMode: 'week',
windowCount: 5,
);
final json = model.toJson();
expect(json['source'], 'messages');
expect(json['window_mode'], 'week');
expect(json['window_count'], 5);
});
});
group('AutomationJobConfigModel', () {
test('fromJson parses all fields correctly', () {
final json = {
'input_template': 'Hello {{name}}',
'enabled_tools': ['tool1', 'tool2'],
'context': {
'source': 'messages',
'window_mode': 'week',
'window_count': 5,
},
};
final model = AutomationJobConfigModel.fromJson(json);
expect(model.inputTemplate, 'Hello {{name}}');
expect(model.enabledTools, ['tool1', 'tool2']);
expect(model.context.source, 'messages');
expect(model.context.windowMode, 'week');
expect(model.context.windowCount, 5);
});
test('fromJson uses defaults for null input', () {
final model = AutomationJobConfigModel.fromJson(null);
expect(model.inputTemplate, '');
expect(model.enabledTools, []);
expect(model.context.source, 'latest_chat');
expect(model.context.windowMode, 'day');
expect(model.context.windowCount, 2);
});
});
group('AutomationJobModel', () {
test('fromJson parses all fields correctly', () {
final json = {
'id': 'job-123',
'owner_id': 'user-456',
'bootstrap_key': 'key-789',
'title': 'Daily Report',
'schedule_type': 'DAILY',
'run_at': '09:00:00',
'timezone': 'America/New_York',
'status': 'ACTIVE',
'is_system': false,
'config': {
'input_template': 'Hello',
'enabled_tools': ['tool1'],
'context': {
'source': 'latest_chat',
'window_mode': 'day',
'window_count': 2,
},
},
'next_run_at': '2024-01-15T09:00:00Z',
'last_run_at': '2024-01-14T09:00:00Z',
'created_at': '2024-01-01T00:00:00Z',
'updated_at': '2024-01-14T12:00:00Z',
};
final model = AutomationJobModel.fromJson(json);
expect(model.id, 'job-123');
expect(model.ownerId, 'user-456');
expect(model.bootstrapKey, 'key-789');
expect(model.title, 'Daily Report');
expect(model.scheduleType, 'DAILY');
expect(model.runAt, '09:00:00');
expect(model.timezone, 'America/New_York');
expect(model.status, 'ACTIVE');
expect(model.isSystem, false);
expect(model.config.inputTemplate, 'Hello');
expect(model.config.enabledTools, ['tool1']);
expect(model.config.context.windowCount, 2);
expect(model.nextRunAt, DateTime.parse('2024-01-15T09:00:00Z'));
expect(model.lastRunAt, DateTime.parse('2024-01-14T09:00:00Z'));
expect(model.createdAt, DateTime.parse('2024-01-01T00:00:00Z'));
expect(model.updatedAt, DateTime.parse('2024-01-14T12:00:00Z'));
});
test('fromJson throws for missing required date fields', () {
final json = <String, dynamic>{
'id': 'job-123',
'owner_id': 'user-456',
'title': 'Test',
'schedule_type': 'DAILY',
'run_at': '09:00:00',
'timezone': 'UTC',
'status': 'ACTIVE',
'is_system': false,
'config': null,
};
expect(
() => AutomationJobModel.fromJson(json),
throwsA(isA<FormatException>()),
);
});
});
group('AutomationJobConfigPatchModel', () {
test('toJson only includes non-null fields', () {
final model = AutomationJobConfigPatchModel(
inputTemplate: 'Updated template',
);
final json = model.toJson();
expect(json.containsKey('input_template'), true);
expect(json.containsKey('enabled_tools'), false);
expect(json.containsKey('context'), false);
expect(json['input_template'], 'Updated template');
});
test('toJson includes all fields when set', () {
final model = AutomationJobConfigPatchModel(
inputTemplate: 'Template',
enabledTools: ['tool1', 'tool2'],
context: MessageContextConfigModel(
source: 'messages',
windowMode: 'week',
windowCount: 3,
),
);
final json = model.toJson();
expect(json['input_template'], 'Template');
expect(json['enabled_tools'], ['tool1', 'tool2']);
expect(json['context'], {
'source': 'messages',
'window_mode': 'week',
'window_count': 3,
});
});
});
group('AutomationJobUpdateRequest', () {
test('toJson only includes non-null fields', () {
final request = AutomationJobUpdateRequest(
title: 'Updated Title',
status: 'INACTIVE',
);
final json = request.toJson();
expect(json.containsKey('title'), true);
expect(json.containsKey('status'), true);
expect(json.containsKey('schedule_type'), false);
expect(json.containsKey('run_at'), false);
expect(json['title'], 'Updated Title');
expect(json['status'], 'INACTIVE');
});
test('toJson includes patch config with only non-null fields', () {
final request = AutomationJobUpdateRequest(
config: AutomationJobConfigPatchModel(inputTemplate: 'New template'),
);
final json = request.toJson();
expect(json.containsKey('config'), true);
final configJson = json['config'] as Map<String, dynamic>;
expect(configJson.containsKey('input_template'), true);
expect(configJson.containsKey('enabled_tools'), false);
expect(configJson.containsKey('context'), false);
});
});
group('AutomationJobCreateRequest', () {
test('toJson serializes correctly', () {
final request = AutomationJobCreateRequest(
title: 'New Job',
scheduleType: 'DAILY',
runAt: '10:00:00',
timezone: 'UTC',
status: 'ACTIVE',
config: AutomationJobConfigModel(
inputTemplate: 'Hello',
enabledTools: ['tool1'],
context: MessageContextConfigModel(
source: 'latest_chat',
windowMode: 'day',
windowCount: 2,
),
),
);
final json = request.toJson();
expect(json['title'], 'New Job');
expect(json['schedule_type'], 'DAILY');
expect(json['run_at'], '10:00:00');
expect(json['timezone'], 'UTC');
expect(json['status'], 'ACTIVE');
expect(json['config'], {
'input_template': 'Hello',
'enabled_tools': ['tool1'],
'context': {
'source': 'latest_chat',
'window_mode': 'day',
'window_count': 2,
},
});
});
});
group('AutomationJobListResponse', () {
test('fromJson parses items correctly', () {
final json = {
'items': [
{
'id': 'job-1',
'owner_id': 'user-1',
'title': 'Job 1',
'schedule_type': 'DAILY',
'run_at': '09:00:00',
'timezone': 'UTC',
'status': 'ACTIVE',
'is_system': false,
'config': null,
'next_run_at': '2024-01-15T09:00:00Z',
'created_at': '2024-01-01T00:00:00Z',
'updated_at': '2024-01-14T12:00:00Z',
},
{
'id': 'job-2',
'owner_id': 'user-1',
'title': 'Job 2',
'schedule_type': 'HOURLY',
'run_at': '00:00:00',
'timezone': 'UTC',
'status': 'INACTIVE',
'is_system': false,
'config': null,
'next_run_at': '2024-01-15T10:00:00Z',
'created_at': '2024-01-02T00:00:00Z',
'updated_at': '2024-01-14T12:00:00Z',
},
],
};
final response = AutomationJobListResponse.fromJson(json);
expect(response.items.length, 2);
expect(response.items[0].id, 'job-1');
expect(response.items[1].id, 'job-2');
});
test('fromJson returns empty list for null items', () {
final response = AutomationJobListResponse.fromJson(null);
expect(response.items, isEmpty);
});
});
}
@@ -0,0 +1,165 @@
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:social_app/features/settings/data/models/automation_job_model.dart';
import 'package:social_app/features/settings/data/services/automation_jobs_api.dart';
import 'package:social_app/features/settings/presentation/cubits/automation_jobs_cubit.dart';
class MockAutomationJobsApi extends Mock implements AutomationJobsApi {}
class FakeAutomationJobUpdateRequest extends Fake
implements AutomationJobUpdateRequest {}
void main() {
late AutomationJobsCubit cubit;
late MockAutomationJobsApi mockApi;
final testJob = AutomationJobModel(
id: '1',
ownerId: 'owner1',
title: 'Test Job',
scheduleType: 'DAILY',
runAt: '08:00:00',
timezone: 'UTC',
status: 'ACTIVE',
isSystem: false,
config: AutomationJobConfigModel(
inputTemplate: '',
enabledTools: const [],
context: MessageContextConfigModel(
source: 'latest_chat',
windowMode: 'day',
windowCount: 2,
),
),
nextRunAt: DateTime(2024, 1, 1),
createdAt: DateTime(2024, 1, 1),
updatedAt: DateTime(2024, 1, 1),
);
setUpAll(() {
registerFallbackValue(FakeAutomationJobUpdateRequest());
});
setUp(() {
mockApi = MockAutomationJobsApi();
cubit = AutomationJobsCubit(mockApi);
});
tearDown(() {
cubit.close();
});
group('AutomationJobsCubit', () {
test('initial state is correct', () {
expect(cubit.state.jobs, isEmpty);
expect(cubit.state.isLoading, isFalse);
expect(cubit.state.error, isNull);
});
blocTest<AutomationJobsCubit, AutomationJobsState>(
'loadJobs success emits loading then jobs',
build: () {
when(() => mockApi.list()).thenAnswer((_) async => [testJob]);
return cubit;
},
act: (c) => c.loadJobs(),
expect: () => [
isA<AutomationJobsState>().having(
(s) => s.isLoading,
'isLoading',
true,
),
isA<AutomationJobsState>()
.having((s) => s.isLoading, 'isLoading', false)
.having((s) => s.jobs, 'jobs', [testJob]),
],
);
blocTest<AutomationJobsCubit, AutomationJobsState>(
'loadJobs failure emits loading then error',
build: () {
when(() => mockApi.list()).thenThrow(Exception('Network error'));
return cubit;
},
act: (c) => c.loadJobs(),
expect: () => [
isA<AutomationJobsState>().having(
(s) => s.isLoading,
'isLoading',
true,
),
isA<AutomationJobsState>()
.having((s) => s.isLoading, 'isLoading', false)
.having((s) => s.error, 'error', isNotNull),
],
);
blocTest<AutomationJobsCubit, AutomationJobsState>(
'deleteJob success calls loadJobs to refresh',
build: () {
when(() => mockApi.delete(any())).thenAnswer((_) async {});
when(() => mockApi.list()).thenAnswer((_) async => []);
return cubit;
},
act: (c) => c.deleteJob('1'),
verify: (_) {
verify(() => mockApi.delete('1')).called(1);
verify(() => mockApi.list()).called(1);
},
);
blocTest<AutomationJobsCubit, AutomationJobsState>(
'deleteJob failure emits error without refreshing',
build: () {
when(() => mockApi.delete(any())).thenThrow(Exception('Delete failed'));
return cubit;
},
act: (c) => c.deleteJob('1'),
expect: () => [
isA<AutomationJobsState>().having((s) => s.error, 'error', isNotNull),
],
verify: (_) {
verify(() => mockApi.delete('1')).called(1);
verifyNever(() => mockApi.list());
},
);
blocTest<AutomationJobsCubit, AutomationJobsState>(
'updateJobStatus success replaces target job',
build: () {
when(
() => mockApi.update(any(), any()),
).thenAnswer((_) async => testJob.copyWith(status: 'disabled'));
return cubit;
},
seed: () => AutomationJobsState(jobs: [testJob]),
act: (c) => c.updateJobStatus(id: '1', enabled: false),
expect: () => [
isA<AutomationJobsState>().having(
(s) => s.jobs.first.status,
'updated status',
'disabled',
),
],
verify: (_) {
verify(() => mockApi.update('1', any())).called(1);
},
);
blocTest<AutomationJobsCubit, AutomationJobsState>(
'updateJobStatus failure emits error',
build: () {
when(
() => mockApi.update(any(), any()),
).thenThrow(Exception('Update failed'));
return cubit;
},
seed: () => AutomationJobsState(jobs: [testJob]),
act: (c) => c.updateJobStatus(id: '1', enabled: false),
expect: () => [
isA<AutomationJobsState>().having((s) => s.error, 'error', isNotNull),
],
);
});
}
@@ -0,0 +1,238 @@
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:social_app/features/settings/data/models/automation_job_model.dart';
import 'package:social_app/features/settings/data/services/automation_jobs_api.dart';
import 'package:social_app/features/settings/presentation/cubits/job_detail_cubit.dart';
class MockAutomationJobsApi extends Mock implements AutomationJobsApi {}
class FakeAutomationJobUpdateRequest extends Fake
implements AutomationJobUpdateRequest {}
class FakeAutomationJobCreateRequest extends Fake
implements AutomationJobCreateRequest {}
void main() {
late JobDetailCubit cubit;
late MockAutomationJobsApi mockApi;
final testJob = AutomationJobModel(
id: '1',
ownerId: 'owner1',
title: 'Test Job',
scheduleType: 'DAILY',
runAt: '08:00:00',
timezone: 'UTC',
status: 'ACTIVE',
isSystem: false,
config: AutomationJobConfigModel(
inputTemplate: '',
enabledTools: const [],
context: MessageContextConfigModel(
source: 'latest_chat',
windowMode: 'day',
windowCount: 2,
),
),
nextRunAt: DateTime(2024, 1, 1),
createdAt: DateTime(2024, 1, 1),
updatedAt: DateTime(2024, 1, 1),
);
setUpAll(() {
registerFallbackValue(FakeAutomationJobUpdateRequest());
registerFallbackValue(FakeAutomationJobCreateRequest());
});
setUp(() {
mockApi = MockAutomationJobsApi();
cubit = JobDetailCubit(mockApi);
});
tearDown(() {
cubit.close();
});
group('JobDetailCubit', () {
test('initial state is correct', () {
expect(cubit.state.job, isNull);
expect(cubit.state.isLoading, isFalse);
expect(cubit.state.isSaving, isFalse);
expect(cubit.state.error, isNull);
});
blocTest<JobDetailCubit, JobDetailState>(
'loadJob success emits loading then job',
build: () {
when(() => mockApi.get(any())).thenAnswer((_) async => testJob);
return cubit;
},
act: (c) => c.loadJob('1'),
expect: () => [
isA<JobDetailState>().having((s) => s.isLoading, 'isLoading', true),
isA<JobDetailState>()
.having((s) => s.isLoading, 'isLoading', false)
.having((s) => s.job, 'job', testJob),
],
);
blocTest<JobDetailCubit, JobDetailState>(
'loadJob failure emits loading then error',
build: () {
when(() => mockApi.get(any())).thenThrow(Exception('Network error'));
return cubit;
},
act: (c) => c.loadJob('1'),
expect: () => [
isA<JobDetailState>().having((s) => s.isLoading, 'isLoading', true),
isA<JobDetailState>()
.having((s) => s.isLoading, 'isLoading', false)
.having((s) => s.error, 'error', isNotNull),
],
);
blocTest<JobDetailCubit, JobDetailState>(
'updateJob success emits saving then job with saving false',
build: () {
when(
() => mockApi.update(any(), any()),
).thenAnswer((_) async => testJob);
return cubit;
},
act: (c) => c.updateJob('1', AutomationJobUpdateRequest()),
expect: () => [
isA<JobDetailState>().having((s) => s.isSaving, 'isSaving', true),
isA<JobDetailState>()
.having((s) => s.isSaving, 'isSaving', false)
.having((s) => s.job, 'job', testJob),
],
);
blocTest<JobDetailCubit, JobDetailState>(
'updateJob failure emits saving then error',
build: () {
when(
() => mockApi.update(any(), any()),
).thenThrow(Exception('Update failed'));
return cubit;
},
act: (c) => c.updateJob('1', AutomationJobUpdateRequest()),
expect: () => [
isA<JobDetailState>().having((s) => s.isSaving, 'isSaving', true),
isA<JobDetailState>()
.having((s) => s.isSaving, 'isSaving', false)
.having((s) => s.error, 'error', isNotNull),
],
);
blocTest<JobDetailCubit, JobDetailState>(
'deleteJob success emits saving then saving false',
build: () {
when(() => mockApi.delete(any())).thenAnswer((_) async {});
return cubit;
},
act: (c) => c.deleteJob('1'),
expect: () => [
isA<JobDetailState>()
.having((s) => s.isSaving, 'isSaving', true)
.having((s) => s.error, 'error', isNull),
isA<JobDetailState>().having((s) => s.isSaving, 'isSaving', false),
],
verify: (_) {
verify(() => mockApi.delete('1')).called(1);
},
);
blocTest<JobDetailCubit, JobDetailState>(
'deleteJob failure emits saving then error',
build: () {
when(() => mockApi.delete(any())).thenThrow(Exception('Delete failed'));
return cubit;
},
act: (c) => c.deleteJob('1'),
expect: () => [
isA<JobDetailState>()
.having((s) => s.isSaving, 'isSaving', true)
.having((s) => s.error, 'error', isNull),
isA<JobDetailState>()
.having((s) => s.isSaving, 'isSaving', false)
.having((s) => s.error, 'error', isNotNull),
],
verify: (_) {
verify(() => mockApi.delete('1')).called(1);
},
);
blocTest<JobDetailCubit, JobDetailState>(
'createJob success emits saving then created job',
build: () {
when(() => mockApi.create(any())).thenAnswer((_) async => testJob);
return cubit;
},
act: (c) => c.createJob(
AutomationJobCreateRequest(
title: 'New Job',
scheduleType: 'daily',
runAt: '08:00:00',
timezone: 'Asia/Shanghai',
status: 'active',
config: AutomationJobConfigModel(
inputTemplate: 'hello',
enabledTools: const ['memory.write'],
context: MessageContextConfigModel(
source: 'latest_chat',
windowMode: 'day',
windowCount: 2,
),
),
),
),
expect: () => [
isA<JobDetailState>()
.having((s) => s.isSaving, 'isSaving', true)
.having((s) => s.error, 'error', isNull),
isA<JobDetailState>()
.having((s) => s.isSaving, 'isSaving', false)
.having((s) => s.job, 'job', testJob),
],
verify: (_) {
verify(() => mockApi.create(any())).called(1);
},
);
blocTest<JobDetailCubit, JobDetailState>(
'createJob failure emits saving then error',
build: () {
when(() => mockApi.create(any())).thenThrow(Exception('Create failed'));
return cubit;
},
act: (c) => c.createJob(
AutomationJobCreateRequest(
title: 'New Job',
scheduleType: 'daily',
runAt: '08:00:00',
timezone: 'Asia/Shanghai',
status: 'active',
config: AutomationJobConfigModel(
inputTemplate: 'hello',
enabledTools: const ['memory.write'],
context: MessageContextConfigModel(
source: 'latest_chat',
windowMode: 'day',
windowCount: 2,
),
),
),
),
expect: () => [
isA<JobDetailState>()
.having((s) => s.isSaving, 'isSaving', true)
.having((s) => s.error, 'error', isNull),
isA<JobDetailState>()
.having((s) => s.isSaving, 'isSaving', false)
.having((s) => s.error, 'error', isNotNull),
],
);
});
}
@@ -0,0 +1,20 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/shared/utils/tool_name_localizer.dart';
void main() {
group('localizeToolName', () {
test('translates dot style tool names', () {
expect(localizeToolName('memory.write'), '写入记忆');
expect(localizeToolName('calendar.read'), '读取日程');
});
test('translates snake style aliases', () {
expect(localizeToolName('memory_write'), '写入记忆');
expect(localizeToolName('calendar_read'), '读取日程');
});
test('returns raw name for unknown tool', () {
expect(localizeToolName('unknown.tool'), 'unknown.tool');
});
});
}