import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/config/api_endpoints.dart'; import '../../../../core/network/dio_client.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/utils/date_formatter.dart'; import '../../../../core/widgets/empty_state.dart'; import '../../../../core/widgets/status_badge.dart'; // --------------------------------------------------------------------------- // TODO 41 – Tasks page: list + create form // --------------------------------------------------------------------------- /// Fetches the full list of ops tasks from the backend. final tasksProvider = FutureProvider>>((ref) async { final dio = ref.watch(dioClientProvider); final response = await dio.get(ApiEndpoints.opsTasks); final data = response.data; if (data is List) { return data.cast>(); } if (data is Map && data.containsKey('items')) { return (data['items'] as List).cast>(); } return []; }); /// Fetches a list of servers for the optional server selector in the create /// task form. final _serversForPickerProvider = FutureProvider>>((ref) async { final dio = ref.watch(dioClientProvider); final response = await dio.get(ApiEndpoints.servers); final data = response.data; if (data is List) return data.cast>(); if (data is Map && data.containsKey('items')) { return (data['items'] as List).cast>(); } return []; }); // --------------------------------------------------------------------------- // Tasks page – ConsumerWidget // --------------------------------------------------------------------------- class TasksPage extends ConsumerWidget { const TasksPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final tasksAsync = ref.watch(tasksProvider); return Scaffold( appBar: AppBar( title: const Text('运维任务'), actions: [ IconButton( icon: const Icon(Icons.refresh), onPressed: () => ref.invalidate(tasksProvider), ), ], ), body: RefreshIndicator( onRefresh: () async => ref.invalidate(tasksProvider), child: tasksAsync.when( data: (tasks) { if (tasks.isEmpty) { return const EmptyState( icon: Icons.assignment_outlined, title: '暂无任务', subtitle: '点击 + 创建新任务', ); } return ListView.builder( padding: const EdgeInsets.all(12), itemCount: tasks.length, itemBuilder: (context, index) { final task = tasks[index]; return _TaskCard(task: task); }, ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.error_outline, color: AppColors.error, size: 48), const SizedBox(height: 12), Text( '加载任务失败', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), ), const SizedBox(height: 6), Text( e.toString(), style: const TextStyle(color: AppColors.textSecondary, fontSize: 13), textAlign: TextAlign.center, ), const SizedBox(height: 16), OutlinedButton.icon( onPressed: () => ref.invalidate(tasksProvider), icon: const Icon(Icons.refresh), label: const Text('重试'), ), ], ), ), ), ), ), floatingActionButton: FloatingActionButton( onPressed: () => _showCreateTaskSheet(context, ref), child: const Icon(Icons.add), ), ); } // ---- Create task bottom sheet -------------------------------------------- void _showCreateTaskSheet(BuildContext context, WidgetRef ref) { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: AppColors.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (context) => _CreateTaskSheet(ref: ref), ); } } // --------------------------------------------------------------------------- // Task card widget // --------------------------------------------------------------------------- class _TaskCard extends StatelessWidget { final Map task; const _TaskCard({required this.task}); @override Widget build(BuildContext context) { final title = task['title'] as String? ?? task['name'] as String? ?? '未命名'; final description = task['description'] as String? ?? ''; final status = task['status'] as String? ?? 'unknown'; final priority = task['priority'] as String? ?? ''; final createdAt = task['created_at'] as String? ?? task['createdAt'] as String?; final timeLabel = createdAt != null ? DateFormatter.timeAgo(DateTime.parse(createdAt)) : ''; return Card( color: AppColors.surface, margin: const EdgeInsets.only(bottom: 10), child: Padding( padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Title row with status badge Row( children: [ Expanded( child: Text( title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 8), StatusBadge(status: status), ], ), if (description.isNotEmpty) ...[ const SizedBox(height: 6), Text( description, style: const TextStyle(color: AppColors.textSecondary, fontSize: 13), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], const SizedBox(height: 8), // Priority + time row Row( children: [ if (priority.isNotEmpty) ...[ _PriorityIndicator(priority: priority), const SizedBox(width: 12), ], Icon(Icons.access_time, size: 14, color: AppColors.textMuted), const SizedBox(width: 4), Text( timeLabel, style: const TextStyle(color: AppColors.textMuted, fontSize: 12), ), ], ), ], ), ), ); } } class _PriorityIndicator extends StatelessWidget { final String priority; const _PriorityIndicator({required this.priority}); Color get _color { switch (priority.toLowerCase()) { case 'critical': case 'urgent': return AppColors.error; case 'high': return AppColors.warning; case 'medium': return AppColors.info; case 'low': default: return AppColors.textMuted; } } @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.flag, size: 14, color: _color), const SizedBox(width: 3), Text( priority, style: TextStyle(color: _color, fontSize: 12, fontWeight: FontWeight.w500), ), ], ); } } // --------------------------------------------------------------------------- // Create task bottom sheet // --------------------------------------------------------------------------- class _CreateTaskSheet extends StatefulWidget { final WidgetRef ref; const _CreateTaskSheet({required this.ref}); @override State<_CreateTaskSheet> createState() => _CreateTaskSheetState(); } class _CreateTaskSheetState extends State<_CreateTaskSheet> { final _formKey = GlobalKey(); final _titleController = TextEditingController(); final _descriptionController = TextEditingController(); String _priority = 'medium'; String? _selectedServerId; bool _submitting = false; static const _priorities = ['low', 'medium', 'high', 'critical']; @override Widget build(BuildContext context) { final serversAsync = widget.ref.watch(_serversForPickerProvider); final bottomInset = MediaQuery.of(context).viewInsets.bottom; return Padding( padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + bottomInset), child: Form( key: _formKey, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Handle bar Center( child: Container( width: 40, height: 4, decoration: BoxDecoration( color: AppColors.textMuted, borderRadius: BorderRadius.circular(2), ), ), ), const SizedBox(height: 16), const Text( '新建任务', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), const SizedBox(height: 20), // Title TextFormField( controller: _titleController, decoration: const InputDecoration( labelText: '标题', hintText: '例如: 重启 web-01 的 nginx', border: OutlineInputBorder(), ), validator: (v) => (v == null || v.trim().isEmpty) ? '请输入标题' : null, textInputAction: TextInputAction.next, ), const SizedBox(height: 14), // Description TextFormField( controller: _descriptionController, decoration: const InputDecoration( labelText: '描述', hintText: '可选详情...', border: OutlineInputBorder(), ), maxLines: 3, textInputAction: TextInputAction.next, ), const SizedBox(height: 14), // Priority dropdown DropdownButtonFormField( value: _priority, decoration: const InputDecoration( labelText: '优先级', border: OutlineInputBorder(), ), items: _priorities .map((p) => DropdownMenuItem( value: p, child: Text(p[0].toUpperCase() + p.substring(1)), )) .toList(), onChanged: (v) { if (v != null) setState(() => _priority = v); }, ), const SizedBox(height: 14), // Optional server selection serversAsync.when( data: (servers) { if (servers.isEmpty) return const SizedBox.shrink(); return DropdownButtonFormField( value: _selectedServerId, decoration: const InputDecoration( labelText: '服务器(可选)', border: OutlineInputBorder(), ), items: [ const DropdownMenuItem(value: null, child: Text('不指定')), ...servers.map((s) { final id = s['id']?.toString() ?? ''; final name = s['hostname'] as String? ?? s['name'] as String? ?? id; return DropdownMenuItem(value: id, child: Text(name)); }), ], onChanged: (v) => setState(() => _selectedServerId = v), ); }, loading: () => const LinearProgressIndicator(), error: (_, __) => const SizedBox.shrink(), ), const SizedBox(height: 24), // Submit button FilledButton( onPressed: _submitting ? null : _submit, child: _submitting ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), ) : const Text('创建任务'), ), const SizedBox(height: 8), ], ), ), ), ); } Future _submit() async { if (!_formKey.currentState!.validate()) return; setState(() => _submitting = true); try { final dio = widget.ref.read(dioClientProvider); final body = { 'title': _titleController.text.trim(), 'description': _descriptionController.text.trim(), 'priority': _priority, }; if (_selectedServerId != null) { body['serverId'] = _selectedServerId; } await dio.post(ApiEndpoints.opsTasks, data: body); // Refresh the task list widget.ref.invalidate(tasksProvider); if (mounted) Navigator.of(context).pop(); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('创建任务失败: $e'), backgroundColor: AppColors.error, ), ); } } finally { if (mounted) setState(() => _submitting = false); } } @override void dispose() { _titleController.dispose(); _descriptionController.dispose(); super.dispose(); } }