import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:it0_app/l10n/app_localizations.dart'; import '../../../../core/config/api_endpoints.dart'; import '../../../../core/errors/error_handler.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/error_view.dart'; import '../../../../core/widgets/status_badge.dart'; import '../../../standing_orders/presentation/pages/standing_orders_page.dart'; // --------------------------------------------------------------------------- // Providers // --------------------------------------------------------------------------- /// 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 – Tabbed (Tasks + Standing Orders) // --------------------------------------------------------------------------- class TasksPage extends ConsumerStatefulWidget { const TasksPage({super.key}); @override ConsumerState createState() => _TasksPageState(); } class _TasksPageState extends ConsumerState with SingleTickerProviderStateMixin { late final TabController _tabController; @override void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); _tabController.addListener(() => setState(() {})); } @override void dispose() { _tabController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(AppLocalizations.of(context).tasksPageTitle), bottom: TabBar( controller: _tabController, tabs: [ Tab(text: AppLocalizations.of(context).opsTasksTab), Tab(text: AppLocalizations.of(context).standingOrdersTab), ], ), ), body: TabBarView( controller: _tabController, children: [ _TasksListBody(ref: ref), _StandingOrdersListBody(ref: ref), ], ), // FAB only on the tasks tab floatingActionButton: _tabController.index == 0 ? FloatingActionButton( onPressed: () => _showCreateTaskSheet(context, ref), child: const Icon(Icons.add), ) : null, ); } 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), ); } } // --------------------------------------------------------------------------- // Tasks list body (tab 1) // --------------------------------------------------------------------------- class _TasksListBody extends StatelessWidget { final WidgetRef ref; const _TasksListBody({required this.ref}); @override Widget build(BuildContext context) { final tasksAsync = ref.watch(tasksProvider); return RefreshIndicator( onRefresh: () async => ref.invalidate(tasksProvider), child: tasksAsync.when( data: (tasks) { if (tasks.isEmpty) { return EmptyState( icon: Icons.assignment_outlined, title: AppLocalizations.of(context).noTasksTitle, subtitle: AppLocalizations.of(context).createNewTaskHint, ); } 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, _) => ErrorView( error: e, onRetry: () => ref.invalidate(tasksProvider), ), ), ); } } // --------------------------------------------------------------------------- // Standing orders list body (tab 2) // --------------------------------------------------------------------------- class _StandingOrdersListBody extends StatelessWidget { final WidgetRef ref; const _StandingOrdersListBody({required this.ref}); @override Widget build(BuildContext context) { final ordersAsync = ref.watch(standingOrdersProvider); return RefreshIndicator( onRefresh: () async => ref.invalidate(standingOrdersProvider), child: ordersAsync.when( data: (orders) { if (orders.isEmpty) { return EmptyState( icon: Icons.rule_outlined, title: AppLocalizations.of(context).noStandingOrdersTitle, subtitle: AppLocalizations.of(context).standingOrdersHint, ); } return ListView.builder( padding: const EdgeInsets.all(12), itemCount: orders.length, itemBuilder: (context, index) { final order = orders[index]; return StandingOrderCard(order: order); }, ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => ErrorView( error: e, onRetry: () => ref.invalidate(standingOrdersProvider), ), ), ); } } // --------------------------------------------------------------------------- // 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), Text( AppLocalizations.of(context).createTaskTitle, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), const SizedBox(height: 20), // Title TextFormField( controller: _titleController, decoration: InputDecoration( labelText: AppLocalizations.of(context).taskTitleLabel, hintText: AppLocalizations.of(context).taskTitleHint, border: const OutlineInputBorder(), ), validator: (v) => (v == null || v.trim().isEmpty) ? '请输入标题' : null, textInputAction: TextInputAction.next, ), const SizedBox(height: 14), // Description TextFormField( controller: _descriptionController, decoration: InputDecoration( labelText: AppLocalizations.of(context).taskDescriptionLabel, hintText: AppLocalizations.of(context).taskDescriptionHint, border: const OutlineInputBorder(), ), maxLines: 3, textInputAction: TextInputAction.next, ), const SizedBox(height: 14), // Priority dropdown DropdownButtonFormField( value: _priority, decoration: InputDecoration( labelText: AppLocalizations.of(context).taskPriorityLabel, border: const 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: InputDecoration( labelText: AppLocalizations.of(context).taskServerOptionalLabel, border: const OutlineInputBorder(), ), items: [ DropdownMenuItem(value: null, child: Text(AppLocalizations.of(context).taskNoServerSelection)), ...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), ) : Text(AppLocalizations.of(context).createTaskButton), ), 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('创建任务失败: ${ErrorHandler.friendlyMessage(e)}'), backgroundColor: AppColors.error, ), ); } } finally { if (mounted) setState(() => _submitting = false); } } @override void dispose() { _titleController.dispose(); _descriptionController.dispose(); super.dispose(); } }