it0/it0_app/lib/features/tasks/presentation/pages/tasks_page.dart

498 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<List<Map<String, dynamic>>>((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<Map<String, dynamic>>();
}
if (data is Map && data.containsKey('items')) {
return (data['items'] as List).cast<Map<String, dynamic>>();
}
return [];
});
/// Fetches a list of servers for the optional server selector in the create
/// task form.
final _serversForPickerProvider =
FutureProvider<List<Map<String, dynamic>>>((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<Map<String, dynamic>>();
if (data is Map && data.containsKey('items')) {
return (data['items'] as List).cast<Map<String, dynamic>>();
}
return [];
});
// ---------------------------------------------------------------------------
// Tasks page Tabbed (Tasks + Standing Orders)
// ---------------------------------------------------------------------------
class TasksPage extends ConsumerStatefulWidget {
const TasksPage({super.key});
@override
ConsumerState<TasksPage> createState() => _TasksPageState();
}
class _TasksPageState extends ConsumerState<TasksPage>
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<String, dynamic> 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<FormState>();
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<String>(
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<String?>(
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<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _submitting = true);
try {
final dio = widget.ref.read(dioClientProvider);
final body = <String, dynamic>{
'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();
}
}