import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../core/theme/app_colors.dart'; import 'lead.dart'; /// Related entity type for polymorphic relations enum RelatedEntityType { lead('lead', 'Lead', LucideIcons.user), account('account', 'Account', LucideIcons.building2), opportunity('opportunity', 'Opportunity', LucideIcons.trendingUp), ticket_('case', 'Ticket', LucideIcons.headset), contact('contact', 'Contact', LucideIcons.contact); final String value; final String label; final IconData icon; const RelatedEntityType(this.value, this.label, this.icon); static RelatedEntityType fromString(String? value) { if (value == null) return RelatedEntityType.lead; return RelatedEntityType.values.firstWhere( (t) => t.value == value.toLowerCase(), orElse: () => RelatedEntityType.lead, ); } } /// Related entity reference class RelatedEntity { final String id; final RelatedEntityType type; final String title; const RelatedEntity({ required this.id, required this.type, required this.title, }); String get displayLabel => '${type.label}: $title'; factory RelatedEntity.fromJson( Map json, RelatedEntityType type, ) { String title = ''; if (type == RelatedEntityType.lead) { final firstName = json['first_name'] as String? ?? ''; final lastName = json['last_name'] as String? ?? ''; title = '$firstName $lastName'.trim(); if (title.isEmpty) title = json['email'] as String? ?? 'Unknown'; } else { title = json['name'] as String? ?? 'Unknown'; } return RelatedEntity( id: json['id']?.toString() ?? '', type: type, title: title, ); } } /// Task status enumeration matching backend STATUS_CHOICES enum TaskStatus { newTask('New', 'New', AppColors.primary500), inProgress('In Progress', 'In Progress', AppColors.warning500), completed('Completed', 'Completed', AppColors.success500); final String value; final String label; final Color color; const TaskStatus(this.value, this.label, this.color); static TaskStatus fromString(String? value) { if (value == null) return TaskStatus.newTask; return TaskStatus.values.firstWhere( (s) => s.value.toLowerCase() == value.toLowerCase(), orElse: () => TaskStatus.newTask, ); } } /// Task model for BottleCRM class Task { final String id; final String title; final String? description; final DateTime? dueDate; final TaskStatus status; final Priority priority; final List>? assignedTo; // Profile UUIDs of assignees, extracted from `assigned_to[].id`. Forms need // the IDs to round-trip through the API; the detail view uses `assignedTo`. final List assignedToIds; final RelatedEntity? relatedTo; // Raw parent FK IDs from the API, exposed separately because the backend // serializes FKs as plain UUID strings (not nested objects), which the // `relatedTo` parser misses. Exactly one of these can be non-null per the // backend's `Task.clean()` invariant. final String? accountId; final String? opportunityId; final String? caseId; final String? leadId; final List tags; // Resolved team names attached to the task (TaskSerializer returns nested // Team objects, but only the display name is used in the UI today). final List teamNames; final Map customFields; // Task creator, backend serializes via UserSerializer, exposing at least // an email. We surface both first/last name (joined) and email so the UI // can pick whichever is non-empty. final String? createdByName; final String? createdByEmail; final DateTime createdAt; final DateTime? updatedAt; const Task({ required this.id, required this.title, this.description, this.dueDate, required this.status, required this.priority, this.assignedTo, this.assignedToIds = const [], this.relatedTo, this.accountId, this.opportunityId, this.caseId, this.leadId, this.tags = const [], this.teamNames = const [], this.customFields = const {}, this.createdByName, this.createdByEmail, required this.createdAt, this.updatedAt, }); /// Check if task is completed bool get completed => status == TaskStatus.completed; /// Check if task is overdue bool get isOverdue { if (completed || dueDate == null) return false; return dueDate!.isBefore(DateTime.now()); } /// Check if task is due today bool get isDueToday { if (dueDate == null) return false; final now = DateTime.now(); return dueDate!.year == now.year && dueDate!.month == now.month && dueDate!.day == now.day; } /// Check if task is due within 2 hours bool get isDueSoon { if (completed || dueDate == null) return false; final now = DateTime.now(); final diff = dueDate!.difference(now); return diff.inHours >= 0 && diff.inHours <= 2; } /// Check if task is upcoming (future, not today) bool get isUpcoming { if (completed || dueDate == null) return false; final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final taskDate = DateTime(dueDate!.year, dueDate!.month, dueDate!.day); return taskDate.isAfter(today); } /// Get task due status category TaskDueStatus get dueStatus { if (completed) return TaskDueStatus.completed; if (isOverdue) return TaskDueStatus.overdue; if (isDueToday) return TaskDueStatus.today; if (dueDate == null) return TaskDueStatus.noDueDate; return TaskDueStatus.upcoming; } /// Days until due (negative if overdue) int? get daysUntilDue { if (dueDate == null) return null; final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final taskDate = DateTime(dueDate!.year, dueDate!.month, dueDate!.day); return taskDate.difference(today).inDays; } /// Get assigned user name (first one if multiple) String get assignedToName { if (assignedTo == null || assignedTo!.isEmpty) return 'Unassigned'; final first = assignedTo!.first; final email = first['user__email'] as String? ?? ''; return email.split('@').first; } /// Factory constructor to create Task from JSON factory Task.fromJson(Map json) { // Parse tags List parsedTags = []; if (json['tags'] != null) { final tagsList = json['tags'] as List; parsedTags = tagsList .map((t) { if (t is Map) { return t['name'] as String? ?? ''; } return t.toString(); }) .where((t) => t.isNotEmpty) .toList(); } // Parse assigned_to List>? parsedAssignedTo; List parsedAssignedToIds = const []; if (json['assigned_to'] != null) { final assignedList = json['assigned_to'] as List; parsedAssignedTo = assignedList .map((a) => a is Map ? a : {}) .toList(); parsedAssignedToIds = parsedAssignedTo .map((a) => a['id']?.toString() ?? '') .where((s) => s.isNotEmpty) .toList(); } // Parse related entity (account, lead, opportunity, ticket). // Backend serializes FKs as plain UUID strings, so prefer the *Id capture // path below for round-tripping; the nested-object branch is kept for any // endpoint that does enrich (e.g. dashboard activity). String? extractId(dynamic raw) { if (raw == null) return null; if (raw is String && raw.isNotEmpty) return raw; if (raw is Map) return raw['id']?.toString(); return null; } final accountId = extractId(json['account']); final opportunityId = extractId(json['opportunity']); final caseId = extractId(json['case']); final leadId = extractId(json['lead']); RelatedEntity? relatedEntity; if (json['account'] is Map) { relatedEntity = RelatedEntity.fromJson( json['account'] as Map, RelatedEntityType.account, ); } else if (json['lead'] is Map) { relatedEntity = RelatedEntity.fromJson( json['lead'] as Map, RelatedEntityType.lead, ); } else if (json['opportunity'] is Map) { relatedEntity = RelatedEntity.fromJson( json['opportunity'] as Map, RelatedEntityType.opportunity, ); } else if (json['case'] is Map) { relatedEntity = RelatedEntity.fromJson( json['case'] as Map, RelatedEntityType.ticket_, ); } final rawCustomFields = json['custom_fields']; final Map parsedCustomFields = rawCustomFields is Map ? Map.from(rawCustomFields) : const {}; // Teams. TaskSerializer returns nested objects. Fall back to a string // if the API ever switches to plain values. final List parsedTeamNames = []; if (json['teams'] is List) { for (final t in json['teams'] as List) { if (t is Map) { final name = t['name'] as String?; if (name != null && name.isNotEmpty) parsedTeamNames.add(name); } else if (t is String && t.isNotEmpty) { parsedTeamNames.add(t); } } } // created_by, UserSerializer shape: {id, email, name, profile_pic}. // Be defensive: backend serializers sometimes return null or a plain id, // and `name` may be an empty string for users who never set it. String? createdByName; String? createdByEmail; final cb = json['created_by']; if (cb is Map) { createdByEmail = cb['email'] as String?; final name = (cb['name'] as String?)?.trim(); if (name != null && name.isNotEmpty) createdByName = name; } return Task( id: json['id']?.toString() ?? '', title: json['title'] as String? ?? '', description: json['description'] as String?, dueDate: json['due_date'] != null ? DateTime.tryParse(json['due_date'] as String) : null, status: TaskStatus.fromString(json['status'] as String?), priority: Priority.fromString(json['priority'] as String?), assignedTo: parsedAssignedTo, assignedToIds: parsedAssignedToIds, relatedTo: relatedEntity, accountId: accountId, opportunityId: opportunityId, caseId: caseId, leadId: leadId, tags: parsedTags, teamNames: parsedTeamNames, customFields: parsedCustomFields, createdByName: createdByName, createdByEmail: createdByEmail, createdAt: json['created_at'] != null ? DateTime.tryParse(json['created_at'] as String) ?? DateTime.now() : DateTime.now(), updatedAt: json['updated_at'] != null ? DateTime.tryParse(json['updated_at'] as String) : null, ); } /// Convert Task to JSON Map toJson() { return { 'id': id, 'title': title, 'description': description, 'due_date': dueDate?.toIso8601String().split('T').first, 'status': status.value, 'priority': priority.label, }; } Task copyWith({ String? id, String? title, String? description, DateTime? dueDate, TaskStatus? status, Priority? priority, List>? assignedTo, List? assignedToIds, RelatedEntity? relatedTo, String? accountId, String? opportunityId, String? caseId, String? leadId, List? tags, List? teamNames, Map? customFields, String? createdByName, String? createdByEmail, DateTime? createdAt, DateTime? updatedAt, }) { return Task( id: id ?? this.id, title: title ?? this.title, description: description ?? this.description, dueDate: dueDate ?? this.dueDate, status: status ?? this.status, priority: priority ?? this.priority, assignedTo: assignedTo ?? this.assignedTo, assignedToIds: assignedToIds ?? this.assignedToIds, relatedTo: relatedTo ?? this.relatedTo, accountId: accountId ?? this.accountId, opportunityId: opportunityId ?? this.opportunityId, caseId: caseId ?? this.caseId, leadId: leadId ?? this.leadId, tags: tags ?? this.tags, teamNames: teamNames ?? this.teamNames, customFields: customFields ?? this.customFields, createdByName: createdByName ?? this.createdByName, createdByEmail: createdByEmail ?? this.createdByEmail, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, ); } @override bool operator ==(Object other) => identical(this, other) || other is Task && runtimeType == other.runtimeType && id == other.id; @override int get hashCode => id.hashCode; } /// Task due status for grouping enum TaskDueStatus { overdue, today, upcoming, noDueDate, completed; String get label { switch (this) { case TaskDueStatus.overdue: return 'Overdue'; case TaskDueStatus.today: return 'Today'; case TaskDueStatus.upcoming: return 'Upcoming'; case TaskDueStatus.noDueDate: return 'No Due Date'; case TaskDueStatus.completed: return 'Completed'; } } }