feat: integrate Firebase Crashlytics, add dynamic task relation labels, and update task model fields

This commit is contained in:
ashwin 2026-05-17 02:09:09 +05:30
parent acf38437bd
commit eaab9972ac
18 changed files with 2061 additions and 345 deletions

View file

@ -146,6 +146,63 @@ class BoardListSerializer(serializers.ModelSerializer):
return BoardTask.objects.filter(column__board=obj).count()
class _MinimalAccountField(serializers.RelatedField):
"""Account FK rendered as `{id, name}` so list rows can show the parent
entity without an extra round-trip. Falls back to id-only when the related
object is missing a name."""
def to_representation(self, value):
return {"id": str(value.pk), "name": getattr(value, "name", "") or ""}
class _MinimalOpportunityField(serializers.RelatedField):
def to_representation(self, value):
return {"id": str(value.pk), "name": getattr(value, "name", "") or ""}
class _MinimalCaseField(serializers.RelatedField):
def to_representation(self, value):
return {"id": str(value.pk), "name": getattr(value, "name", "") or ""}
class _MinimalLeadField(serializers.RelatedField):
def to_representation(self, value):
first = getattr(value, "first_name", "") or ""
last = getattr(value, "last_name", "") or ""
name = f"{first} {last}".strip() or (getattr(value, "email", "") or "")
return {"id": str(value.pk), "name": name}
class TaskListSerializer(serializers.ModelSerializer):
"""Slim payload for /api/tasks/ list pages — drops the comment/attachment
bodies (sometimes hundreds of rows per task) and the contacts/teams M2Ms
that the list UI doesn't render. Use TaskSerializer for the detail view."""
assigned_to = ProfileSerializer(read_only=True, many=True)
tags = TagsSerializer(read_only=True, many=True)
account = _MinimalAccountField(read_only=True)
opportunity = _MinimalOpportunityField(read_only=True)
case = _MinimalCaseField(read_only=True)
lead = _MinimalLeadField(read_only=True)
class Meta:
model = Task
fields = (
"id",
"title",
"status",
"priority",
"due_date",
"account",
"opportunity",
"case",
"lead",
"assigned_to",
"tags",
"created_at",
)
class TaskSerializer(serializers.ModelSerializer):
created_by = UserSerializer()
assigned_to = ProfileSerializer(read_only=True, many=True)
@ -154,6 +211,10 @@ class TaskSerializer(serializers.ModelSerializer):
tags = TagsSerializer(read_only=True, many=True)
task_attachment = AttachmentsSerializer(read_only=True, many=True)
task_comments = CommentSerializer(read_only=True, many=True)
account = _MinimalAccountField(read_only=True)
opportunity = _MinimalOpportunityField(read_only=True)
case = _MinimalCaseField(read_only=True)
lead = _MinimalLeadField(read_only=True)
class Meta:
model = Task
@ -188,19 +249,6 @@ class TaskCreateSerializer(serializers.ModelSerializer):
self.fields["title"].required = True
def validate_title(self, title):
if self.instance:
if (
Task.objects.filter(title__iexact=title, org=self.org)
.exclude(id=self.instance.id)
.exists()
):
raise serializers.ValidationError("Task already exists with this title")
else:
if Task.objects.filter(title__iexact=title, org=self.org).exists():
raise serializers.ValidationError("Task already exists with this title")
return title
def validate(self, attrs):
"""Validate that task has at most one parent entity."""
attrs = super().validate(attrs)

View file

@ -10,8 +10,6 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from accounts.models import Account
from accounts.serializer import AccountSerializer
from common.custom_fields import validate_payload as validate_custom_fields_payload
from common.models import Attachments, Comment, CustomFieldDefinition, Profile, Tags, Teams
from common.permissions import HasOrgContext
@ -24,7 +22,6 @@ from common.serializer import (
)
from contacts.models import Contact
from cases.models import Case
from contacts.serializer import ContactSerializer
from leads.models import Lead
from opportunity.models import Opportunity
from tasks import swagger_params
@ -34,9 +31,9 @@ from tasks.serializer import (
TaskCreateSerializer,
TaskCreateSwaggerSerializer,
TaskDetailEditSwaggerSerializer,
TaskListSerializer,
TaskSerializer,
)
from tasks.utils import PRIORITY_CHOICES, STATUS_CHOICES
class TaskListView(APIView, LimitOffsetPagination):
@ -45,24 +42,17 @@ class TaskListView(APIView, LimitOffsetPagination):
def get_context_data(self, **kwargs):
params = self.request.query_params
queryset = self.model.objects.filter(org=self.request.profile.org).order_by(
"-id"
queryset = (
self.model.objects.filter(org=self.request.profile.org)
.select_related("account", "opportunity", "case", "lead")
.prefetch_related("assigned_to__user", "tags")
.order_by("-id")
)
accounts = Account.objects.filter(org=self.request.profile.org)
contacts = Contact.objects.filter(org=self.request.profile.org)
if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin:
queryset = queryset.filter(
Q(assigned_to__in=[self.request.profile])
| Q(created_by=self.request.profile.user)
)
accounts = accounts.filter(
Q(created_by=self.request.profile.user)
| Q(assigned_to=self.request.profile)
).distinct()
contacts = contacts.filter(
Q(created_by=self.request.profile.user)
| Q(assigned_to=self.request.profile)
).distinct()
if params:
if params.get("title"):
@ -112,7 +102,10 @@ class TaskListView(APIView, LimitOffsetPagination):
results_tasks = self.paginate_queryset(
queryset.distinct(), self.request, view=self
)
tasks = TaskSerializer(results_tasks, many=True).data
# Slim serializer: drops comments/attachments/contacts/teams from list
# rows and renders FKs as {id, name}. Detail view still uses
# TaskSerializer for the full nested payload.
tasks = TaskListSerializer(results_tasks, many=True).data
if results_tasks:
offset = queryset.filter(id__gte=results_tasks[-1].id).count()
if offset == queryset.count():
@ -123,13 +116,9 @@ class TaskListView(APIView, LimitOffsetPagination):
{
"tasks_count": self.count,
"offset": offset,
"tasks": tasks,
}
)
context["tasks"] = tasks
context["status"] = STATUS_CHOICES
context["priority"] = PRIORITY_CHOICES
context["accounts_list"] = AccountSerializer(accounts, many=True).data
context["contacts_list"] = ContactSerializer(contacts, many=True).data
return context
@extend_schema(
@ -142,11 +131,7 @@ class TaskListView(APIView, LimitOffsetPagination):
fields={
"tasks_count": serializers.IntegerField(),
"offset": serializers.IntegerField(allow_null=True),
"tasks": TaskSerializer(many=True),
"status": serializers.ListField(),
"priority": serializers.ListField(),
"accounts_list": AccountSerializer(many=True),
"contacts_list": ContactSerializer(many=True),
"tasks": TaskListSerializer(many=True),
},
)
},
@ -340,17 +325,6 @@ class TaskDetailView(APIView):
role="ADMIN", org=self.request.profile.org
).order_by("user__email")
if self.request.profile == self.task_obj.created_by:
user_assgn_list.append(self.request.profile.id)
if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin:
if self.request.profile.id not in user_assgn_list:
return Response(
{
"error": True,
"errors": "You don't have Permission to perform this action",
},
status=status.HTTP_403_FORBIDDEN,
)
team_ids = [user.id for user in self.task_obj.get_team_users]
all_user_ids = users.values_list("id", flat=True)
users_excluding_team_id = set(all_user_ids) - set(team_ids)
@ -438,13 +412,21 @@ class TaskDetailView(APIView):
},
status=status.HTTP_403_FORBIDDEN,
)
comment_serializer = CommentSerializer(data=params)
if comment_serializer.is_valid():
if params.get("comment"):
comment_serializer.save(
task_id=self.task_obj.id,
commented_by_id=self.request.profile.id,
)
task_content_type = ContentType.objects.get_for_model(Task)
comment_text = params.get("comment")
if comment_text:
# Use the generic ContentType-based create directly. Validating
# through CommentSerializer fails silently here because that
# serializer requires object_id and org, neither of which the
# client sends — is_valid() returns False and the save is skipped,
# leaving the user with a 200 and no comment.
Comment.objects.create(
content_type=task_content_type,
object_id=self.task_obj.id,
comment=comment_text,
commented_by=self.request.profile,
org=self.request.profile.org,
)
if self.request.FILES.get("task_attachment"):
attachment = Attachments()
@ -455,7 +437,6 @@ class TaskDetailView(APIView):
attachment.org = self.request.profile.org
attachment.save()
task_content_type = ContentType.objects.get_for_model(Task)
comments = Comment.objects.filter(
content_type=task_content_type,
object_id=self.task_obj.id,
@ -493,6 +474,21 @@ class TaskDetailView(APIView):
def put(self, request, pk, **kwargs):
params = request.data
self.task_obj = self.get_object(pk)
# Match the role gate used by GET/POST/PATCH/DELETE — without this,
# any authenticated user in the org could PUT to any task UUID even
# though they can't read it.
if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin:
if not (
(self.request.profile == self.task_obj.created_by)
or (self.request.profile in self.task_obj.assigned_to.all())
):
return Response(
{
"error": True,
"errors": "You don't have Permission to perform this action",
},
status=status.HTTP_403_FORBIDDEN,
)
serializer = TaskCreateSerializer(
data=params,
instance=self.task_obj,

View file

@ -7,6 +7,7 @@ plugins {
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
}
dependencies {
// ...

View file

@ -4,7 +4,8 @@ buildscript {
mavenCentral()
}
dependencies {
classpath("com.google.gms:google-services:4.4.0")
classpath("com.google.gms:google-services:4.4.4")
classpath("com.google.firebase:firebase-crashlytics-gradle:3.0.7")
}
}

View file

@ -145,6 +145,25 @@ class DashboardTask {
String? get relatedTo => accountName ?? leadName;
factory DashboardTask.fromJson(Map<String, dynamic> json) {
// Backend's TaskSerializer returns `account`/`lead` as plain FK UUIDs
// (strings), not nested {id, name} objects. Only extract a display name
// when the field is a Map — otherwise we have a UUID with no name and
// skip the related-entity badge rather than crashing on String indexing.
String? accountName;
final account = json['account'];
if (account is Map<String, dynamic>) {
accountName = account['name'] as String?;
}
String? leadName;
final lead = json['lead'];
if (lead is Map<String, dynamic>) {
final first = (lead['first_name'] as String?) ?? '';
final last = (lead['last_name'] as String?) ?? '';
final joined = '$first $last'.trim();
if (joined.isNotEmpty) leadName = joined;
}
return DashboardTask(
id: json['id']?.toString() ?? '',
title: json['title'] as String? ?? 'Untitled',
@ -154,11 +173,8 @@ class DashboardTask {
dueDate: json['due_date'] != null
? DateTime.tryParse(json['due_date'] as String)
: null,
accountName: json['account']?['name'] as String?,
leadName: json['lead'] != null
? '${json['lead']['first_name'] ?? ''} ${json['lead']['last_name'] ?? ''}'
.trim()
: null,
accountName: accountName,
leadName: leadName,
);
}
}

View file

@ -104,7 +104,15 @@ class Task {
final String? caseId;
final String? leadId;
final List<String> 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<String> teamNames;
final Map<String, dynamic> 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;
@ -123,7 +131,10 @@ class Task {
this.caseId,
this.leadId,
this.tags = const [],
this.teamNames = const [],
this.customFields = const {},
this.createdByName,
this.createdByEmail,
required this.createdAt,
this.updatedAt,
});
@ -265,6 +276,32 @@ class Task {
? Map<String, dynamic>.from(rawCustomFields)
: const {};
// Teams — TaskSerializer returns nested objects. Fall back to a string
// if the API ever switches to plain values.
final List<String> parsedTeamNames = [];
if (json['teams'] is List) {
for (final t in json['teams'] as List<dynamic>) {
if (t is Map<String, dynamic>) {
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<String, dynamic>) {
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? ?? '',
@ -282,7 +319,10 @@ class Task {
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(),
@ -319,7 +359,10 @@ class Task {
String? caseId,
String? leadId,
List<String>? tags,
List<String>? teamNames,
Map<String, dynamic>? customFields,
String? createdByName,
String? createdByEmail,
DateTime? createdAt,
DateTime? updatedAt,
}) {
@ -338,7 +381,10 @@ class Task {
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,
);

View file

@ -0,0 +1,32 @@
// Hand-written Firebase config (not from `flutterfire configure`) — Android
// is the only configured platform. Values mirror `android/app/google-services.json`.
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
class DefaultFirebaseOptions {
static FirebaseOptions get currentPlatform {
if (kIsWeb) {
throw UnsupportedError(
'DefaultFirebaseOptions: web is not configured for this app.',
);
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return android;
default:
throw UnsupportedError(
'DefaultFirebaseOptions: ${defaultTargetPlatform.name} '
'is not configured for this app.',
);
}
}
static const FirebaseOptions android = FirebaseOptions(
apiKey: 'AIzaSyDsA2wgQIw-bVgwODNMIF482gVRn5xdANU',
appId: '1:1072513761792:android:c170ae72445200ecd74ee5',
messagingSenderId: '1072513761792',
projectId: 'bottlecrm-io',
storageBucket: 'bottlecrm-io.firebasestorage.app',
);
}

View file

@ -1,33 +1,67 @@
import 'dart:async';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/theme/theme.dart';
import 'firebase_options.dart';
import 'routes/app_router.dart';
import 'services/auth_service.dart';
import 'services/crash_reporting.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
runZonedGuarded<Future<void>>(
() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize services
await AuthService().initialize();
// Firebase + Crashlytics — Android only. iOS has no GoogleService-Info.plist
// yet, so skip init there to avoid a startup crash.
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// Off in debug to avoid spamming the Crashlytics console with dev noise.
await FirebaseCrashlytics.instance
.setCrashlyticsCollectionEnabled(!kDebugMode);
FlutterError.onError =
FirebaseCrashlytics.instance.recordFlutterFatalError;
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true;
};
}
// Set system UI overlay style
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
systemNavigationBarColor: AppColors.surface,
systemNavigationBarIconBrightness: Brightness.dark,
),
await AuthService().initialize();
// Tag any already-signed-in session so the first uncaught error has a user.
await CrashReporting.applyFromAuth(AuthService());
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
systemNavigationBarColor: AppColors.surface,
systemNavigationBarIconBrightness: Brightness.dark,
),
);
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
runApp(const ProviderScope(child: BottleCRMApp()));
},
(error, stack) {
// Catch-all for anything that escaped the zone — e.g. errors from
// microtasks scheduled before PlatformDispatcher.onError was wired.
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
}
},
);
// Lock to portrait mode for optimal CRM experience
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
runApp(const ProviderScope(child: BottleCRMApp()));
}
/// BottleCRM - Main Application Widget

View file

@ -1,12 +1,123 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../config/api_config.dart';
import '../data/models/attachment.dart';
import '../data/models/comment.dart';
import '../data/models/custom_field_definition.dart';
import '../data/models/lead.dart' show Priority;
import '../data/models/task.dart';
import '../services/api_service.dart';
export '../services/api_service.dart' show ApiResponse;
/// Full task-detail snapshot returned by `GET /api/tasks/{id}/`.
/// Bundles everything the detail screen needs in one round-trip so we don't
/// chase the same endpoint twice for comments / attachments / CF schema.
class TaskDetailResult {
final Task task;
final List<Comment> comments;
final List<Attachment> attachments;
final List<CustomFieldDefinition> customFieldDefinitions;
const TaskDetailResult({
required this.task,
this.comments = const [],
this.attachments = const [],
this.customFieldDefinitions = const [],
});
TaskDetailResult copyWith({
Task? task,
List<Comment>? comments,
List<Attachment>? attachments,
List<CustomFieldDefinition>? customFieldDefinitions,
}) {
return TaskDetailResult(
task: task ?? this.task,
comments: comments ?? this.comments,
attachments: attachments ?? this.attachments,
customFieldDefinitions:
customFieldDefinitions ?? this.customFieldDefinitions,
);
}
}
/// Server-side filter set for the tasks list. Held as an immutable value so
/// the screen can diff "did the user actually change something" before
/// triggering a refetch.
class TaskFilters {
final String? search;
final TaskStatus? status;
final Priority? priority;
// Assignee profile id. null = anyone (no filter).
final String? assignedToId;
// Display label for the active assignee filter (e.g. "Me"). Not sent to
// backend; used by the UI to render the chip without a profile lookup.
final String? assignedToLabel;
// ISO date strings (yyyy-MM-dd) — backend supports due_date__gte/__lte.
// Used by the calendar view to fetch only the visible month.
final String? dueDateGte;
final String? dueDateLte;
const TaskFilters({
this.search,
this.status,
this.priority,
this.assignedToId,
this.assignedToLabel,
this.dueDateGte,
this.dueDateLte,
});
bool get isEmpty =>
(search == null || search!.isEmpty) &&
status == null &&
priority == null &&
(assignedToId == null || assignedToId!.isEmpty);
bool get isActive => !isEmpty;
TaskFilters copyWith({
String? search,
TaskStatus? status,
Priority? priority,
String? assignedToId,
String? assignedToLabel,
String? dueDateGte,
String? dueDateLte,
}) {
return TaskFilters(
search: search ?? this.search,
status: status ?? this.status,
priority: priority ?? this.priority,
assignedToId: assignedToId ?? this.assignedToId,
assignedToLabel: assignedToLabel ?? this.assignedToLabel,
dueDateGte: dueDateGte ?? this.dueDateGte,
dueDateLte: dueDateLte ?? this.dueDateLte,
);
}
/// copyWith can't distinguish "unchanged" from "set to null", so use this
/// to explicitly clear individual fields.
TaskFilters cleared({
bool search = false,
bool status = false,
bool priority = false,
bool assignedTo = false,
bool dueDate = false,
}) {
return TaskFilters(
search: search ? null : this.search,
status: status ? null : this.status,
priority: priority ? null : this.priority,
assignedToId: assignedTo ? null : assignedToId,
assignedToLabel: assignedTo ? null : assignedToLabel,
dueDateGte: dueDate ? null : dueDateGte,
dueDateLte: dueDate ? null : dueDateLte,
);
}
}
/// Paginated tasks snapshot — wrapped by AsyncValue.
class TasksListData {
final List<Task> tasks;
@ -40,41 +151,32 @@ class TasksNotifier extends AsyncNotifier<TasksListData> {
final ApiService _apiService = ApiService();
static const int _pageSize = 20;
TaskFilters _filters = const TaskFilters();
TaskFilters get filters => _filters;
@override
Future<TasksListData> build() => _fetchPage(offset: 0);
Future<void> refresh({
String? search,
String? status,
String? priority,
}) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(
() => _fetchPage(
offset: 0,
search: search,
status: status,
priority: priority,
),
);
/// Replace filters and refetch from offset 0. Use this from the screen
/// when chips/search change so the in-flight queryParams stay consistent
/// across `refresh()` and `loadMore()`.
Future<void> setFilters(TaskFilters filters) async {
_filters = filters;
await refresh();
}
Future<void> loadMore({
String? search,
String? status,
String? priority,
}) async {
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _fetchPage(offset: 0));
}
Future<void> loadMore() async {
final current = state.value;
if (current == null || !current.hasMore) return;
if (state.isLoading) return;
state = await AsyncValue.guard(() async {
final next = await _fetchPage(
offset: current.currentOffset,
search: search,
status: status,
priority: priority,
);
final next = await _fetchPage(offset: current.currentOffset);
return current.copyWith(
tasks: [...current.tasks, ...next.tasks],
totalCount: next.totalCount,
@ -84,20 +186,25 @@ class TasksNotifier extends AsyncNotifier<TasksListData> {
});
}
Future<TasksListData> _fetchPage({
required int offset,
String? search,
String? status,
String? priority,
}) async {
Future<TasksListData> _fetchPage({required int offset}) async {
final queryParams = <String, String>{
'limit': _pageSize.toString(),
'offset': offset.toString(),
};
if (search != null && search.isNotEmpty) queryParams['search'] = search;
if (status != null && status.isNotEmpty) queryParams['status'] = status;
if (priority != null && priority.isNotEmpty) {
queryParams['priority'] = priority;
final f = _filters;
if (f.search != null && f.search!.isNotEmpty) {
queryParams['search'] = f.search!;
}
if (f.status != null) queryParams['status'] = f.status!.value;
if (f.priority != null) queryParams['priority'] = f.priority!.label;
if (f.assignedToId != null && f.assignedToId!.isNotEmpty) {
queryParams['assigned_to'] = f.assignedToId!;
}
if (f.dueDateGte != null && f.dueDateGte!.isNotEmpty) {
queryParams['due_date__gte'] = f.dueDateGte!;
}
if (f.dueDateLte != null && f.dueDateLte!.isNotEmpty) {
queryParams['due_date__lte'] = f.dueDateLte!;
}
final url = Uri.parse(
@ -147,23 +254,96 @@ class TasksNotifier extends AsyncNotifier<TasksListData> {
/// Fetch a single task from the API.
Future<Task?> getTaskById(String taskId) async {
final detail = await getTaskDetail(taskId);
return detail?.task;
}
/// Fetch full task detail incl. comments, attachments and custom-field
/// schema. The detail endpoint returns all of these in one payload — splitting
/// them into separate models lets the UI render each section without
/// re-parsing the raw map.
Future<TaskDetailResult?> getTaskDetail(String taskId) async {
try {
final url = '${ApiConfig.tasks}$taskId/';
final response = await _apiService.get(url);
if (!response.success || response.data == null) {
debugPrint('TasksNotifier: detail fetch failed - ${response.message}');
return null;
}
final data = response.data!;
final taskData = data['task_obj'] as Map<String, dynamic>?;
if (taskData == null) return null;
if (response.success && response.data != null) {
final taskData = response.data!['task_obj'] as Map<String, dynamic>?;
if (taskData != null) {
return Task.fromJson(taskData);
final comments = <Comment>[];
for (final c in (data['comments'] as List<dynamic>? ?? [])) {
if (c is Map<String, dynamic>) comments.add(Comment.fromJson(c));
}
final attachments = <Attachment>[];
for (final a in (data['attachments'] as List<dynamic>? ?? [])) {
if (a is Map<String, dynamic>) attachments.add(Attachment.fromJson(a));
}
final defs = <CustomFieldDefinition>[];
for (final d in (data['custom_field_definitions'] as List<dynamic>? ?? [])) {
if (d is Map<String, dynamic>) {
defs.add(CustomFieldDefinition.fromJson(d));
}
}
return null;
defs.sort((a, b) {
final byOrder = a.displayOrder.compareTo(b.displayOrder);
return byOrder != 0 ? byOrder : a.label.compareTo(b.label);
});
return TaskDetailResult(
task: Task.fromJson(taskData),
comments: comments,
attachments: attachments,
customFieldDefinitions: defs,
);
} catch (e) {
debugPrint('TasksNotifier: Exception getting task - $e');
debugPrint('TasksNotifier: Exception getting task detail - $e');
return null;
}
}
/// Post a new comment to a task. Backend uses the same POST as the
/// attachment-upload endpoint (`TaskDetailView.post`), and echoes the
/// refreshed `comments` + `attachments` arrays in the response.
Future<ApiResponse<Map<String, dynamic>>> addTaskComment(
String taskId,
String comment,
) async {
try {
final url = '${ApiConfig.tasks}$taskId/';
return await _apiService.post(url, {'comment': comment});
} catch (e) {
return ApiResponse(success: false, message: e.toString(), statusCode: 0);
}
}
/// Delete one comment by its UUID. Routed via `/api/tasks/comment/{id}/`.
Future<ApiResponse<Map<String, dynamic>>> deleteTaskComment(
String commentId,
) async {
try {
final url = '${ApiConfig.tasks}comment/$commentId/';
return await _apiService.delete(url);
} catch (e) {
return ApiResponse(success: false, message: e.toString(), statusCode: 0);
}
}
/// Delete one attachment by its UUID. Routed via `/api/tasks/attachment/{id}/`.
Future<ApiResponse<Map<String, dynamic>>> deleteTaskAttachment(
String attachmentId,
) async {
try {
final url = '${ApiConfig.tasks}attachment/$attachmentId/';
return await _apiService.delete(url);
} catch (e) {
return ApiResponse(success: false, message: e.toString(), statusCode: 0);
}
}
Future<ApiResponse<Map<String, dynamic>>> createTask(
Map<String, dynamic> data,
) async {
@ -182,7 +362,12 @@ class TasksNotifier extends AsyncNotifier<TasksListData> {
) async {
try {
final url = '${ApiConfig.tasks}$taskId/';
final response = await _apiService.put(url, data);
// PATCH (not PUT) so M2M fields the form doesn't touch — `tags`,
// `contacts`, `teams` — are left alone. The backend PUT handler
// unconditionally clears those M2Ms before reading the request body
// (tasks/views/task_views.py), so a mobile PUT silently wipes them.
// PATCH only mutates keys present in the payload.
final response = await _apiService.patch(url, data);
if (response.success) await refresh();
return response;
} catch (e) {

View file

@ -1092,31 +1092,48 @@ class _LeadDetailScreenState extends ConsumerState<LeadDetailScreen>
.read(leadsProvider.notifier)
.addComment(widget.leadId, text);
if (mounted) {
setState(() => _isAddingNote = false);
if (!mounted) return;
setState(() => _isAddingNote = false);
if (response.success) {
_noteController.clear();
// Refresh lead to get updated comments
await _fetchLead();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Note added'),
behavior: SnackBarBehavior.floating,
),
);
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(response.message ?? 'Failed to add note'),
behavior: SnackBarBehavior.floating,
backgroundColor: AppColors.danger600,
),
);
}
if (!response.success) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(response.message ?? 'Failed to add note'),
behavior: SnackBarBehavior.floating,
backgroundColor: AppColors.danger600,
),
);
return;
}
_noteController.clear();
// The POST returns the updated comments list — use it directly instead
// of refetching. The previous refetch path could silently fail (provider
// catches and returns null on any parse error), leaving the new comment
// invisible even though the server saved it.
final commentsData = response.data?['comments'];
if (_lead != null && commentsData is List) {
final newComments = commentsData
.whereType<Map<String, dynamic>>()
.map(Comment.fromJson)
.toList();
setState(() {
_lead = _lead!.copyWith(comments: newComments);
});
} else {
// Response missing comments (shouldn't happen) — fall back to refetch
// so the user doesn't end up staring at stale data.
await _fetchLead();
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Note added'),
behavior: SnackBarBehavior.floating,
),
);
}
Future<void> _editComment(String commentId, String currentText) async {
@ -1134,16 +1151,7 @@ class _LeadDetailScreenState extends ConsumerState<LeadDetailScreen>
.updateComment(commentId, newText);
if (!mounted) return;
if (response.success) {
await _fetchLead();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Note updated'),
behavior: SnackBarBehavior.floating,
),
);
} else {
if (!response.success) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(response.message ?? 'Failed to update note'),
@ -1151,7 +1159,34 @@ class _LeadDetailScreenState extends ConsumerState<LeadDetailScreen>
backgroundColor: AppColors.danger600,
),
);
return;
}
// PATCH returns only {error, message} — patch the comment locally.
if (_lead != null) {
final updated = _lead!.comments.map((c) {
return c.id == commentId
? Comment(
id: c.id,
comment: newText,
commentedOn: c.commentedOn,
commentedById: c.commentedById,
commentedByName: c.commentedByName,
commentedByEmail: c.commentedByEmail,
)
: c;
}).toList();
setState(() {
_lead = _lead!.copyWith(comments: updated);
});
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Note updated'),
behavior: SnackBarBehavior.floating,
),
);
}
Future<void> _deleteComment(String commentId) async {
@ -1159,28 +1194,34 @@ class _LeadDetailScreenState extends ConsumerState<LeadDetailScreen>
.read(leadsProvider.notifier)
.deleteComment(commentId);
if (mounted) {
if (response.success) {
// Refresh lead to get updated comments
await _fetchLead();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Note deleted'),
behavior: SnackBarBehavior.floating,
),
);
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(response.message ?? 'Failed to delete note'),
behavior: SnackBarBehavior.floating,
backgroundColor: AppColors.danger600,
),
);
}
if (!mounted) return;
if (!response.success) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(response.message ?? 'Failed to delete note'),
behavior: SnackBarBehavior.floating,
backgroundColor: AppColors.danger600,
),
);
return;
}
// DELETE returns only {error, message} — remove locally.
if (_lead != null) {
final filtered = _lead!.comments
.where((c) => c.id != commentId)
.toList();
setState(() {
_lead = _lead!.copyWith(comments: filtered);
});
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Note deleted'),
behavior: SnackBarBehavior.floating,
),
);
}
Widget _buildCard({

View file

@ -2,9 +2,17 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/theme/theme.dart';
import '../../data/models/models.dart';
import '../../data/models/attachment.dart';
import '../../data/models/comment.dart';
import '../../data/models/custom_field_definition.dart';
import '../../providers/deals_provider.dart';
import '../../providers/leads_provider.dart';
import '../../providers/lookup_provider.dart';
import '../../providers/tasks_provider.dart';
import '../../providers/tickets_provider.dart';
import '../../widgets/common/common.dart';
/// Task Detail Screen
@ -19,32 +27,45 @@ class TaskDetailScreen extends ConsumerStatefulWidget {
}
class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
Task? _task;
TaskDetailResult? _detail;
bool _isLoading = true;
bool _isUpdating = false;
bool _isPostingComment = false;
String? _error;
final TextEditingController _commentController = TextEditingController();
final FocusNode _commentFocus = FocusNode();
Task? get _task => _detail?.task;
@override
void initState() {
super.initState();
_fetchTask();
}
@override
void dispose() {
_commentController.dispose();
_commentFocus.dispose();
super.dispose();
}
Future<void> _fetchTask() async {
setState(() {
_isLoading = true;
_error = null;
});
final task = await ref
final detail = await ref
.read(tasksProvider.notifier)
.getTaskById(widget.taskId);
.getTaskDetail(widget.taskId);
if (mounted) {
setState(() {
_isLoading = false;
_task = task;
if (task == null) {
_detail = detail;
if (detail == null) {
_error = 'Failed to load task';
}
});
@ -99,10 +120,28 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
return Scaffold(
backgroundColor: AppColors.surface,
body: CustomScrollView(
slivers: [
// App Bar
SliverAppBar(
// Body is a Column so the composer + action button can live at the
// bottom of the body proper, *outside* the sliver scroll view. Putting
// a TextField inside a Scaffold.bottomNavigationBar slot didn't reliably
// raise the soft keyboard on Android — this layout does.
body: Column(
children: [
Expanded(
child: CustomScrollView(
slivers: [
_buildSliverAppBar(),
SliverToBoxAdapter(child: _buildContent()),
],
),
),
_buildBottomActions(),
],
),
);
}
Widget _buildSliverAppBar() {
return SliverAppBar(
expandedHeight: 200,
pinned: true,
backgroundColor: _getHeaderColor(),
@ -161,14 +200,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
),
],
flexibleSpace: FlexibleSpaceBar(background: _buildHeader()),
),
// Content
SliverToBoxAdapter(child: _buildContent()),
],
),
bottomNavigationBar: _buildBottomActions(),
);
);
}
Color _getHeaderColor() {
@ -297,111 +329,109 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
}
Widget _buildContent() {
final task = _task!;
final related = _resolveRelatedEntity();
final defs = _detail?.customFieldDefinitions ?? const <CustomFieldDefinition>[];
final comments = _detail?.comments ?? const <Comment>[];
final attachments = _detail?.attachments ?? const <Attachment>[];
final createdByLabel = task.createdByName?.isNotEmpty == true
? task.createdByName!
: (task.createdByEmail ?? '');
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Description Card
if (_task!.description != null && _task!.description!.isNotEmpty)
if (task.description != null && task.description!.isNotEmpty) ...[
_buildCard(
title: 'Description',
icon: LucideIcons.fileText,
child: Text(
_task!.description!,
task.description!,
style: AppTypography.body.copyWith(
color: AppColors.textSecondary,
height: 1.6,
),
),
),
if (_task!.description != null && _task!.description!.isNotEmpty)
const SizedBox(height: 16),
],
// Related Entity Card
if (_task!.relatedTo != null)
_buildCard(
title: 'Related To',
icon: LucideIcons.link2,
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.primary100,
borderRadius: BorderRadius.circular(10),
),
child: Icon(
_task!.relatedTo!.type.icon,
size: 20,
color: AppColors.primary600,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_task!.relatedTo!.type.label,
style: AppTypography.caption.copyWith(
color: AppColors.textSecondary,
),
),
Text(
_task!.relatedTo!.title,
style: AppTypography.label,
),
],
),
),
Icon(
LucideIcons.chevronRight,
size: 20,
color: AppColors.textTertiary,
),
],
),
),
if (related != null) ...[
_buildRelatedCard(related),
const SizedBox(height: 16),
],
if (_task!.relatedTo != null) const SizedBox(height: 16),
// Assigned To Card
if (_task!.assignedTo != null && _task!.assignedTo!.isNotEmpty)
if (task.assignedTo != null && task.assignedTo!.isNotEmpty) ...[
_buildCard(
title: 'Assigned To',
icon: LucideIcons.user,
child: Row(
children: [
UserAvatar(name: _task!.assignedToName, size: AvatarSize.md),
const SizedBox(width: 12),
Text(_task!.assignedToName, style: AppTypography.label),
],
child: _buildAssigneesList(task.assignedTo!),
),
const SizedBox(height: 16),
],
if (task.teamNames.isNotEmpty) ...[
_buildCard(
title: 'Teams',
icon: LucideIcons.users,
child: Wrap(
spacing: 8,
runSpacing: 8,
children: task.teamNames
.map((name) => LabelPill(label: name))
.toList(),
),
),
if (_task!.assignedTo != null && _task!.assignedTo!.isNotEmpty)
const SizedBox(height: 16),
],
// Tags Card
if (_task!.tags.isNotEmpty)
if (task.tags.isNotEmpty) ...[
_buildCard(
title: 'Tags',
icon: LucideIcons.tag,
child: Wrap(
spacing: 8,
runSpacing: 8,
children: _task!.tags
children: task.tags
.map((tag) => LabelPill(label: tag))
.toList(),
),
),
const SizedBox(height: 16),
],
if (_task!.tags.isNotEmpty) const SizedBox(height: 16),
if (defs.any((d) => _cfValueOf(d).isNotEmpty)) ...[
_buildCard(
title: 'Custom Fields',
icon: LucideIcons.listChecks,
child: Column(
children: _buildCustomFieldRows(defs),
),
),
const SizedBox(height: 16),
],
_buildCommentsCard(comments),
const SizedBox(height: 16),
if (attachments.isNotEmpty) ...[
_buildCard(
title: 'Attachments',
icon: LucideIcons.paperclip,
child: Column(
children: [
for (var i = 0; i < attachments.length; i++) ...[
if (i > 0) const Divider(height: 16),
_buildAttachmentRow(attachments[i]),
],
],
),
),
const SizedBox(height: 16),
],
// Metadata Card
_buildCard(
title: 'Details',
icon: LucideIcons.info,
@ -409,14 +439,22 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
children: [
_buildInfoRow(
'Created',
_formatDateTime(_task!.createdAt),
_formatDateTime(task.createdAt),
LucideIcons.plus,
),
if (_task!.updatedAt != null) ...[
if (createdByLabel.isNotEmpty) ...[
const Divider(height: 20),
_buildInfoRow(
'Created by',
createdByLabel,
LucideIcons.user,
),
],
if (task.updatedAt != null) ...[
const Divider(height: 20),
_buildInfoRow(
'Last Updated',
_formatDateTime(_task!.updatedAt!),
_formatDateTime(task.updatedAt!),
LucideIcons.pencil,
),
],
@ -430,6 +468,398 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
);
}
/// Resolve the linked parent entity (account / lead / opportunity / case)
/// from the cached lookup providers. The backend serializes these FKs as
/// plain UUID strings, not nested objects — so without this lookup the
/// "Related To" card silently disappears even when a parent IS attached.
_RelatedRef? _resolveRelatedEntity() {
final t = _task;
if (t == null) return null;
if (t.accountId != null) {
String label = 'Selected account';
for (final a in ref.watch(accountsProvider)) {
if (a.id == t.accountId) {
label = a.name;
break;
}
}
// No /accounts/:id route on mobile yet — show the card, but don't fake
// a chevron the user can't follow.
return _RelatedRef(
typeLabel: 'Account',
icon: LucideIcons.building,
label: label,
path: null,
);
}
if (t.leadId != null) {
String label = 'Selected lead';
for (final l in ref.watch(leadsListProvider)) {
if (l.id == t.leadId) {
final n = '${l.firstName} ${l.lastName}'.trim();
label = n.isEmpty ? (l.email) : n;
break;
}
}
return _RelatedRef(
typeLabel: 'Lead',
icon: LucideIcons.userPlus,
label: label,
path: '/leads/${t.leadId}',
);
}
if (t.opportunityId != null) {
String label = 'Selected deal';
for (final d in ref.watch(dealsListProvider)) {
if (d.id == t.opportunityId) {
label = d.title;
break;
}
}
return _RelatedRef(
typeLabel: 'Opportunity',
icon: LucideIcons.trendingUp,
label: label,
path: '/deals/${t.opportunityId}',
);
}
if (t.caseId != null) {
String label = 'Selected ticket';
for (final c in ref.watch(ticketsListProvider)) {
if (c.id == t.caseId) {
label = c.name;
break;
}
}
return _RelatedRef(
typeLabel: 'Ticket',
icon: LucideIcons.lifeBuoy,
label: label,
path: '/tickets/${t.caseId}',
);
}
return null;
}
Widget _buildRelatedCard(_RelatedRef r) {
return _buildCard(
title: 'Related To',
icon: LucideIcons.link2,
child: InkWell(
onTap: r.path == null ? null : () => context.push(r.path!),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.primary100,
borderRadius: BorderRadius.circular(10),
),
child: Icon(r.icon, size: 20, color: AppColors.primary600),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
r.typeLabel,
style: AppTypography.caption.copyWith(
color: AppColors.textSecondary,
),
),
Text(r.label, style: AppTypography.label),
],
),
),
if (r.path != null)
Icon(
LucideIcons.chevronRight,
size: 20,
color: AppColors.textTertiary,
),
],
),
),
),
);
}
Widget _buildAssigneesList(List<Map<String, dynamic>> assignees) {
return Column(
children: [
for (var i = 0; i < assignees.length; i++) ...[
if (i > 0) const SizedBox(height: 12),
Row(
children: [
UserAvatar(
name: _assigneeDisplayName(assignees[i]),
size: AvatarSize.sm,
),
const SizedBox(width: 12),
Expanded(
child: Text(
_assigneeDisplayName(assignees[i]),
style: AppTypography.label,
),
),
],
),
],
],
);
}
String _assigneeDisplayName(Map<String, dynamic> profile) {
// ProfileSerializer wraps the user under `user_details` ({email, name, …}).
// Some older endpoints still return user__email at the top level — keep
// that fallback so a payload shape change doesn't blank out the avatar.
final details = profile['user_details'];
String? email;
String? name;
if (details is Map<String, dynamic>) {
email = details['email'] as String?;
name = (details['name'] as String?)?.trim();
}
email ??= profile['user__email'] as String?;
if (name != null && name.isNotEmpty) return name;
if (email != null && email.isNotEmpty) return email.split('@').first;
return 'Unassigned';
}
/// Render the raw value stored under `custom_fields[def.key]` as a
/// display string. Empty / missing values return '' so the caller can
/// skip them.
String _cfValueOf(CustomFieldDefinition def) {
final raw = _task?.customFields[def.key];
if (raw == null) return '';
if (raw is bool) return raw ? 'Yes' : 'No';
if (raw is List) return raw.join(', ');
final s = raw.toString();
return s;
}
List<Widget> _buildCustomFieldRows(List<CustomFieldDefinition> defs) {
final rows = <Widget>[];
for (final def in defs) {
final value = _cfValueOf(def);
if (value.isEmpty) continue;
if (rows.isNotEmpty) rows.add(const Divider(height: 20));
rows.add(_buildInfoRow(def.label, value, LucideIcons.tag));
}
return rows;
}
Widget _buildCommentsCard(List<Comment> comments) {
// The composer lives in the bottomNavigationBar (see _buildBottomActions);
// putting it here too caused focus/keyboard issues when the sliver
// viewport pushed it offscreen on tap.
return _buildCard(
title: 'Comments',
icon: LucideIcons.messageCircle,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (comments.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
'No comments yet. Use the box below to add one.',
style: AppTypography.body.copyWith(
color: AppColors.textTertiary,
),
),
)
else
Column(
children: [
for (var i = 0; i < comments.length; i++) ...[
if (i > 0) const SizedBox(height: 10),
_buildCommentTile(comments[i]),
],
],
),
],
),
);
}
Widget _buildCommentTile(Comment comment) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.gray50,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
comment.comment,
style: AppTypography.body.copyWith(
color: AppColors.textPrimary,
height: 1.5,
),
),
const SizedBox(height: 8),
Row(
children: [
UserAvatar(name: comment.authorName, size: AvatarSize.xs),
const SizedBox(width: 8),
Expanded(
child: Text(
comment.authorName,
style: AppTypography.caption.copyWith(
fontWeight: FontWeight.w500,
),
),
),
Text(
_formatTimeAgo(comment.commentedOn),
style: AppTypography.caption.copyWith(
color: AppColors.textTertiary,
),
),
IconButton(
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
icon: Icon(
LucideIcons.trash2,
size: 14,
color: AppColors.textTertiary,
),
onPressed: () => _confirmDeleteComment(comment),
),
],
),
],
),
);
}
Widget _buildCommentComposer() {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: _commentController,
focusNode: _commentFocus,
enabled: !_isPostingComment,
minLines: 1,
maxLines: 4,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
decoration: InputDecoration(
hintText: 'Add a comment…',
hintStyle: AppTypography.body.copyWith(
color: AppColors.textTertiary,
),
filled: true,
fillColor: AppColors.gray100,
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide.none,
),
),
),
),
const SizedBox(width: 8),
Material(
color: _isPostingComment
? AppColors.gray400
: AppColors.primary600,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: _isPostingComment ? null : _addComment,
child: Padding(
padding: const EdgeInsets.all(12),
child: _isPostingComment
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(LucideIcons.send, color: Colors.white, size: 18),
),
),
),
],
);
}
Widget _buildAttachmentRow(Attachment att) {
return InkWell(
onTap: att.filePath == null ? null : () => _openAttachment(att),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.primary50,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
LucideIcons.file,
size: 18,
color: AppColors.primary600,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
att.fileName,
style: AppTypography.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (att.createdBy != null && att.createdBy!.isNotEmpty)
Text(
att.createdBy!,
style: AppTypography.caption.copyWith(
color: AppColors.textTertiary,
),
),
],
),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: Icon(
LucideIcons.trash2,
size: 16,
color: AppColors.textTertiary,
),
onPressed: () => _confirmDeleteAttachment(att),
),
],
),
),
);
}
Widget _buildCard({
required String title,
required IconData icon,
@ -487,37 +917,49 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
Widget _buildBottomActions() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border(top: BorderSide(color: AppColors.border)),
),
child: SafeArea(
child: Row(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Toggle Status Button
Expanded(
child: _isUpdating
? const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: _task!.completed
? SecondaryButton(
label: 'Reopen',
icon: LucideIcons.rotateCcw,
onPressed: _toggleTaskStatus,
isFullWidth: true,
)
: PrimaryButton(
label: 'Complete',
icon: LucideIcons.checkCircle2,
onPressed: _toggleTaskStatus,
isFullWidth: true,
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
child: _buildCommentComposer(),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Row(
children: [
Expanded(
child: _isUpdating
? const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: _task!.completed
? SecondaryButton(
label: 'Reopen',
icon: LucideIcons.rotateCcw,
onPressed: _toggleTaskStatus,
isFullWidth: true,
)
: PrimaryButton(
label: 'Complete',
icon: LucideIcons.checkCircle2,
onPressed: _toggleTaskStatus,
isFullWidth: true,
),
),
],
),
),
],
),
@ -681,6 +1123,178 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
}
}
Future<void> _addComment() async {
final text = _commentController.text.trim();
if (text.isEmpty) return;
setState(() => _isPostingComment = true);
final response = await ref
.read(tasksProvider.notifier)
.addTaskComment(widget.taskId, text);
if (!mounted) return;
if (!response.success) {
setState(() => _isPostingComment = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(response.message ?? 'Failed to add comment'),
behavior: SnackBarBehavior.floating,
backgroundColor: AppColors.danger600,
),
);
return;
}
_commentController.clear();
// Use the POST response directly — it returns the refreshed comments and
// attachments. The previous _fetchTask() refetch flashed the full-page
// spinner (since _fetchTask always sets _isLoading=true) and would
// silently fail if getTaskDetail returned null, leaving the new comment
// invisible even though the server saved it.
final data = response.data;
final commentsData = data?['comments'];
final attachmentsData = data?['attachments'];
if (_detail != null && commentsData is List) {
final newComments = commentsData
.whereType<Map<String, dynamic>>()
.map(Comment.fromJson)
.toList();
final newAttachments = attachmentsData is List
? attachmentsData
.whereType<Map<String, dynamic>>()
.map(Attachment.fromJson)
.toList()
: _detail!.attachments;
setState(() {
_detail = _detail!.copyWith(
comments: newComments,
attachments: newAttachments,
);
_isPostingComment = false;
});
} else {
// Response shape unexpected — fall back to refetch.
await _fetchTask();
if (mounted) setState(() => _isPostingComment = false);
}
}
void _confirmDeleteComment(Comment comment) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete comment?'),
content: const Text('This cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
Navigator.pop(dialogContext);
final response = await ref
.read(tasksProvider.notifier)
.deleteTaskComment(comment.id);
if (!mounted) return;
if (response.success || response.statusCode == 204) {
if (_detail != null) {
setState(() {
_detail = _detail!.copyWith(
comments: _detail!.comments
.where((c) => c.id != comment.id)
.toList(),
);
});
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(response.message ?? 'Delete failed'),
behavior: SnackBarBehavior.floating,
backgroundColor: AppColors.danger600,
),
);
}
},
child: Text('Delete', style: TextStyle(color: AppColors.danger600)),
),
],
),
);
}
void _confirmDeleteAttachment(Attachment att) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete attachment?'),
content: Text('Remove "${att.fileName}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
Navigator.pop(dialogContext);
final response = await ref
.read(tasksProvider.notifier)
.deleteTaskAttachment(att.id);
if (!mounted) return;
if (response.success || response.statusCode == 204) {
if (_detail != null) {
setState(() {
_detail = _detail!.copyWith(
attachments: _detail!.attachments
.where((a) => a.id != att.id)
.toList(),
);
});
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(response.message ?? 'Delete failed'),
behavior: SnackBarBehavior.floating,
backgroundColor: AppColors.danger600,
),
);
}
},
child: Text('Delete', style: TextStyle(color: AppColors.danger600)),
),
],
),
);
}
Future<void> _openAttachment(Attachment att) async {
final path = att.filePath;
if (path == null || path.isEmpty) return;
final uri = Uri.tryParse(path);
if (uri == null) return;
final launched = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
if (!launched && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Could not open attachment'),
behavior: SnackBarBehavior.floating,
),
);
}
}
String _formatTimeAgo(DateTime dt) {
final diff = DateTime.now().difference(dt);
if (diff.inDays > 30) return '${(diff.inDays / 30).floor()}mo ago';
if (diff.inDays > 0) return '${diff.inDays}d ago';
if (diff.inHours > 0) return '${diff.inHours}h ago';
if (diff.inMinutes > 0) return '${diff.inMinutes}m ago';
return 'Just now';
}
String _formatDueDate(DateTime date) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
@ -734,3 +1348,20 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
return '${months[date.month - 1]} ${date.day}, ${date.year}';
}
}
/// Resolved parent-entity reference for the Related-To card. `path` is null
/// when the mobile app has no detail screen for that entity type (e.g.
/// accounts) — the card renders without a chevron in that case.
class _RelatedRef {
final String typeLabel;
final IconData icon;
final String label;
final String? path;
const _RelatedRef({
required this.typeLabel,
required this.icon,
required this.label,
required this.path,
});
}

View file

@ -52,7 +52,6 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
List<String> _assigneeIds = [];
_RelatedKind? _relatedKind;
String? _relatedId;
String? _relatedLabel;
Map<String, dynamic> _customFields = {};
bool _isLoading = false;
bool _isFetchingTask = false;
@ -121,7 +120,43 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
_relatedKind = _RelatedKind.ticket;
_relatedId = task.caseId;
}
_relatedLabel = task.relatedTo?.title;
}
// Resolve the label for the currently-linked entity from cached lookup
// providers. The TaskSerializer returns FKs as plain UUIDs (not nested
// objects), so we have no embedded name on the edit fetch — we look it up
// in the same provider used by the picker. Returns null if the cache
// doesn't have it yet (provider not loaded); the UI falls back to a
// generic "Selected" hint until the provider warms up.
String? _resolveRelatedLabel() {
final id = _relatedId;
final kind = _relatedKind;
if (id == null || kind == null) return null;
switch (kind) {
case _RelatedKind.account:
for (final a in ref.watch(accountsProvider)) {
if (a.id == id) return a.name;
}
return null;
case _RelatedKind.lead:
for (final l in ref.watch(leadsListProvider)) {
if (l.id == id) {
final n = '${l.firstName} ${l.lastName}'.trim();
return n.isEmpty ? l.email : n;
}
}
return null;
case _RelatedKind.opportunity:
for (final d in ref.watch(dealsListProvider)) {
if (d.id == id) return d.title;
}
return null;
case _RelatedKind.ticket:
for (final t in ref.watch(ticketsListProvider)) {
if (t.id == id) return t.name;
}
return null;
}
}
bool get _hasUnsavedChanges {
@ -432,6 +467,7 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
prefixIcon: LucideIcons.checkSquare,
textInputAction: TextInputAction.next,
textCapitalization: TextCapitalization.sentences,
maxLength: 200,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Task title is required';
@ -765,9 +801,10 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
}
Widget _buildRelatedField() {
final label = _resolveRelatedLabel();
final displayValue = _relatedKind == null
? null
: '${_relatedKind!.label}: ${_relatedLabel ?? "Selected"}';
: '${_relatedKind!.label}: ${label ?? "Selected"}';
return _PickerRow(
label: 'Linked record',
icon: _relatedKind?.icon ?? LucideIcons.link2,
@ -778,7 +815,6 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
: () => setState(() {
_relatedKind = null;
_relatedId = null;
_relatedLabel = null;
}),
child: displayValue == null
? null
@ -856,7 +892,6 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
setState(() {
_relatedKind = kind;
_relatedId = pick.id;
_relatedLabel = pick.name;
});
}
break;
@ -872,11 +907,9 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
icon: LucideIcons.user,
);
if (pick != null) {
final n = '${pick.firstName} ${pick.lastName}'.trim();
setState(() {
_relatedKind = kind;
_relatedId = pick.id;
_relatedLabel = n.isEmpty ? pick.email : n;
});
}
break;
@ -892,7 +925,6 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
setState(() {
_relatedKind = kind;
_relatedId = pick.id;
_relatedLabel = pick.title;
});
}
break;
@ -908,7 +940,6 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
setState(() {
_relatedKind = kind;
_relatedId = pick.id;
_relatedLabel = pick.name;
});
}
break;

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@ -5,6 +7,7 @@ import 'package:lucide_icons/lucide_icons.dart';
import 'package:table_calendar/table_calendar.dart';
import '../../core/theme/theme.dart';
import '../../data/models/models.dart';
import '../../providers/profile_provider.dart';
import '../../providers/tasks_provider.dart';
import '../../widgets/cards/task_row.dart';
import '../../widgets/common/common.dart';
@ -26,6 +29,63 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
DateTime _selectedDay = DateTime.now();
CalendarFormat _calendarFormat = CalendarFormat.month;
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
Timer? _searchDebounce;
TaskFilters _filters = const TaskFilters();
static const _searchDebounceMs = 350;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_searchDebounce?.cancel();
_searchController.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
/// Trigger paginated fetch when the user scrolls near the bottom. Without
/// this, the provider's loadMore() never runs and users silently see only
/// the first page (20 tasks).
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
ref.read(tasksProvider.notifier).loadMore();
}
}
void _applyFilters(TaskFilters next) {
setState(() => _filters = next);
ref.read(tasksProvider.notifier).setFilters(next);
}
void _onSearchChanged(String value) {
setState(() {}); // refresh clear-button visibility
_searchDebounce?.cancel();
_searchDebounce = Timer(
const Duration(milliseconds: _searchDebounceMs),
() => _applyFilters(_filters.copyWith(search: value.trim())),
);
}
void _clearSearch() {
_searchController.clear();
_searchDebounce?.cancel();
_applyFilters(_filters.cleared(search: true));
}
void _clearAllFilters() {
_searchController.clear();
_searchDebounce?.cancel();
_applyFilters(const TaskFilters());
}
// Get task dates for calendar markers
Set<String> _getTaskDates(List<Task> tasks) {
@ -47,9 +107,39 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
});
}
/// Set due-date range to the visible month so calendar markers cover every
/// task in that month — not just the 20 currently paged in.
void _fetchMonthForCalendar(DateTime focusedDay) {
final first = DateTime(focusedDay.year, focusedDay.month, 1);
final last = DateTime(focusedDay.year, focusedDay.month + 1, 0);
final next = _filters.copyWith(
dueDateGte: _formatDateKey(first),
dueDateLte: _formatDateKey(last),
);
_applyFilters(next);
}
void _toggleViewMode() {
setState(() {
_viewMode = _viewMode == TaskViewMode.calendar
? TaskViewMode.list
: TaskViewMode.calendar;
});
if (_viewMode == TaskViewMode.calendar) {
_fetchMonthForCalendar(_focusedDay);
} else if (_filters.dueDateGte != null || _filters.dueDateLte != null) {
// Drop the month window when returning to list view so the user sees
// all their tasks again.
_applyFilters(_filters.cleared(dueDate: true));
}
}
@override
Widget build(BuildContext context) {
final tasksAsync = ref.watch(tasksProvider);
// Eagerly subscribe so the "My tasks" toggle can read profile.id without
// waiting for a separate fetch when the user taps it.
ref.watch(profileProvider);
final allTasks = tasksAsync.value?.tasks ?? const <Task>[];
final isLoading = tasksAsync.isLoading;
final error = tasksAsync.error?.toString();
@ -62,7 +152,6 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
elevation: 0,
scrolledUnderElevation: 1,
actions: [
// View toggle
IconButton(
icon: Icon(
_viewMode == TaskViewMode.calendar
@ -70,33 +159,31 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
: LucideIcons.calendar,
size: 22,
),
onPressed: () {
setState(() {
_viewMode = _viewMode == TaskViewMode.calendar
? TaskViewMode.list
: TaskViewMode.calendar;
});
},
onPressed: _toggleViewMode,
),
// Add task
IconButton(
icon: const Icon(LucideIcons.plus, size: 22),
onPressed: () => _navigateToCreateTask(),
),
],
),
body: _buildBody(allTasks, isLoading, error),
body: Column(
children: [
if (_viewMode == TaskViewMode.list) ...[
_buildSearchBar(),
_buildFilterBar(),
],
Expanded(child: _buildBody(allTasks, isLoading, error)),
],
),
);
}
Widget _buildBody(List<Task> allTasks, bool isLoading, String? error) {
// Show loading spinner for initial load
if (isLoading && allTasks.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
// Show error state
if (error != null && allTasks.isEmpty) {
return Center(
child: Padding(
@ -141,6 +228,143 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
);
}
Widget _buildSearchBar() {
final hasQuery = _searchController.text.isNotEmpty;
return Container(
color: AppColors.surfaceDim,
padding: const EdgeInsets.fromLTRB(12, 6, 12, 6),
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
style: AppTypography.body,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'Search task title',
hintStyle: AppTypography.body.copyWith(color: AppColors.textTertiary),
prefixIcon: Icon(
LucideIcons.search,
color: AppColors.textTertiary,
size: 18,
),
suffixIcon: hasQuery
? IconButton(
icon: Icon(
LucideIcons.x,
color: AppColors.textTertiary,
size: 16,
),
onPressed: _clearSearch,
)
: null,
filled: true,
fillColor: AppColors.gray100,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: AppColors.primary500, width: 1),
),
),
),
);
}
Widget _buildFilterBar() {
return Container(
color: AppColors.surface,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(12, 6, 12, 8),
child: Row(
children: [
_TaskFilterChip(
label: _filters.assignedToLabel ?? 'Anyone',
isActive: _filters.assignedToId != null,
icon: LucideIcons.user,
onTap: _showAssigneeFilter,
),
const SizedBox(width: 6),
_TaskFilterChip(
label: _filters.status?.label ?? 'Status',
isActive: _filters.status != null,
onTap: _showStatusFilter,
),
const SizedBox(width: 6),
_TaskFilterChip(
label: _filters.priority?.label ?? 'Priority',
isActive: _filters.priority != null,
onTap: _showPriorityFilter,
),
if (_filters.isActive) ...[
const SizedBox(width: 6),
GestureDetector(
onTap: _clearAllFilters,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 5,
),
decoration: BoxDecoration(
color: AppColors.danger100,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.x, size: 12, color: AppColors.danger600),
const SizedBox(width: 3),
Text(
'Clear',
style: AppTypography.caption.copyWith(
color: AppColors.danger600,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
],
),
),
);
}
/// Compact summary line so overdue/today counts are visible without scrolling
/// past the Completed accordion.
Widget _buildHeaderSummary(int overdue, int today, int upcoming, int total) {
final parts = <String>[];
if (overdue > 0) parts.add('$overdue overdue');
if (today > 0) parts.add('$today today');
if (upcoming > 0) parts.add('$upcoming upcoming');
final summary = parts.isEmpty
? '$total task${total == 1 ? '' : 's'}'
: parts.join(' · ');
return Container(
width: double.infinity,
color: AppColors.surfaceDim,
padding: const EdgeInsets.fromLTRB(12, 6, 12, 8),
child: Text(
summary,
style: AppTypography.caption.copyWith(
color: overdue > 0 ? AppColors.danger600 : AppColors.textSecondary,
fontWeight: overdue > 0 ? FontWeight.w600 : null,
),
),
);
}
Widget _buildCalendarView(List<Task> allTasks) {
final taskDates = _getTaskDates(allTasks);
final tasksForSelectedDate = _getTasksForSelectedDate(allTasks);
@ -170,9 +394,12 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
},
onPageChanged: (focusedDay) {
_focusedDay = focusedDay;
// Refetch the new month so markers reflect what's actually due.
// Without this, navigating past page 1 looks empty even when
// tasks exist in that month.
_fetchMonthForCalendar(focusedDay);
},
calendarStyle: CalendarStyle(
// Today
todayDecoration: BoxDecoration(
color: AppColors.primary100,
shape: BoxShape.circle,
@ -181,7 +408,6 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
color: AppColors.primary700,
fontWeight: FontWeight.w600,
),
// Selected
selectedDecoration: BoxDecoration(
color: AppColors.primary600,
shape: BoxShape.circle,
@ -190,13 +416,11 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
color: Colors.white,
fontWeight: FontWeight.w600,
),
// Default
defaultTextStyle: AppTypography.body,
weekendTextStyle: AppTypography.body,
outsideTextStyle: AppTypography.body.copyWith(
color: AppColors.gray300,
),
// Markers
markerDecoration: BoxDecoration(
color: AppColors.primary500,
shape: BoxShape.circle,
@ -307,6 +531,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
onToggle: () => _toggleTask(task),
onTap: () => _showTaskDetail(task),
onDelete: () => _deleteTask(task),
onComplete: task.completed ? null : () => _toggleTask(task),
);
},
),
@ -332,15 +557,24 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
return _buildAllCaughtUpState();
}
final hasMore = ref.watch(tasksProvider).value?.hasMore ?? false;
return RefreshIndicator(
onRefresh: () async {
await ref.read(tasksProvider.notifier).refresh();
},
child: SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.only(bottom: 100),
child: Column(
children: [
// Overdue
_buildHeaderSummary(
overdueTasks.length,
todayTasks.length,
upcomingTasks.length,
allTasks.length,
),
if (overdueTasks.isNotEmpty)
TaskGroup(
title: 'Overdue',
@ -349,9 +583,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
onToggle: _toggleTask,
onTap: _showTaskDetail,
onDelete: _deleteTask,
onComplete: (t) => _toggleTask(t),
),
// Today
if (todayTasks.isNotEmpty)
TaskGroup(
title: 'Today',
@ -360,9 +594,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
onToggle: _toggleTask,
onTap: _showTaskDetail,
onDelete: _deleteTask,
onComplete: (t) => _toggleTask(t),
),
// Upcoming
if (upcomingTasks.isNotEmpty)
TaskGroup(
title: 'Upcoming',
@ -371,9 +605,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
onToggle: _toggleTask,
onTap: _showTaskDetail,
onDelete: _deleteTask,
onComplete: (t) => _toggleTask(t),
),
// No Due Date
if (noDueDateTasks.isNotEmpty)
TaskGroup(
title: 'No Due Date',
@ -382,9 +616,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
onToggle: _toggleTask,
onTap: _showTaskDetail,
onDelete: _deleteTask,
onComplete: (t) => _toggleTask(t),
),
// Completed (collapsed by default)
if (completedTasks.isNotEmpty)
TaskGroup(
title: 'Completed',
@ -394,10 +628,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
onToggle: _toggleTask,
onTap: _showTaskDetail,
onDelete: _deleteTask,
onComplete: (t) => _toggleTask(t),
),
// Loading indicator at bottom
if (isLoading && allTasks.isNotEmpty)
if (hasMore && isLoading)
const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
@ -452,6 +686,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
}
Widget _buildAllCaughtUpState() {
final isFiltered = _filters.isActive;
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
@ -466,19 +701,21 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.checkCircle2,
isFiltered ? LucideIcons.search : LucideIcons.checkCircle2,
size: 40,
color: AppColors.success600,
),
),
const SizedBox(height: 20),
Text(
'All caught up!',
isFiltered ? 'No matching tasks' : 'All caught up!',
style: AppTypography.h2.copyWith(color: AppColors.textPrimary),
),
const SizedBox(height: 8),
Text(
'No pending tasks. Create a new task to get started.',
isFiltered
? 'Try adjusting your search or filters.'
: 'No pending tasks. Create a new task to get started.',
style: AppTypography.body.copyWith(
color: AppColors.textSecondary,
),
@ -486,9 +723,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
),
const SizedBox(height: 24),
PrimaryButton(
label: 'Add Task',
icon: LucideIcons.plus,
onPressed: () => _navigateToCreateTask(),
label: isFiltered ? 'Clear filters' : 'Add Task',
icon: isFiltered ? LucideIcons.x : LucideIcons.plus,
onPressed: isFiltered ? _clearAllFilters : _navigateToCreateTask,
),
],
),
@ -530,7 +767,6 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
Future<void> _navigateToCreateTask() async {
final result = await context.push('/tasks/create');
if (result == true && mounted) {
// Refresh after creating a task
ref.read(tasksProvider.notifier).refresh();
}
}
@ -563,7 +799,6 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
}
void _showTaskDetail(Task task) {
// Navigate to full detail screen
context.push('/tasks/${task.id}');
}
@ -619,4 +854,224 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
String _formatDateKey(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
// ─── Filter sheets ────────────────────────────────────────────────────────
void _showAssigneeFilter() {
final profileId = ref.read(profileProvider).value?.id;
showModalBottomSheet(
context: context,
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => _TaskFilterSheet(
title: 'Filter by Owner',
options: [
_TaskFilterOption(
label: 'Anyone',
isSelected: _filters.assignedToId == null,
onTap: () {
_applyFilters(_filters.cleared(assignedTo: true));
Navigator.pop(context);
},
),
if (profileId != null)
_TaskFilterOption(
label: 'My tasks',
isSelected: _filters.assignedToId == profileId,
onTap: () {
_applyFilters(
_filters.copyWith(
assignedToId: profileId,
assignedToLabel: 'Me',
),
);
Navigator.pop(context);
},
),
],
),
);
}
void _showStatusFilter() {
showModalBottomSheet(
context: context,
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => _TaskFilterSheet(
title: 'Filter by Status',
options: [
_TaskFilterOption(
label: 'All statuses',
isSelected: _filters.status == null,
onTap: () {
_applyFilters(_filters.cleared(status: true));
Navigator.pop(context);
},
),
...TaskStatus.values.map(
(s) => _TaskFilterOption(
label: s.label,
isSelected: _filters.status == s,
onTap: () {
_applyFilters(_filters.copyWith(status: s));
Navigator.pop(context);
},
),
),
],
),
);
}
void _showPriorityFilter() {
showModalBottomSheet(
context: context,
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => _TaskFilterSheet(
title: 'Filter by Priority',
options: [
_TaskFilterOption(
label: 'All priorities',
isSelected: _filters.priority == null,
onTap: () {
_applyFilters(_filters.cleared(priority: true));
Navigator.pop(context);
},
),
// Task model only supports Low/Medium/High at the backend level.
...Priority.values.where((p) => p != Priority.urgent).map(
(p) => _TaskFilterOption(
label: p.label,
isSelected: _filters.priority == p,
onTap: () {
_applyFilters(_filters.copyWith(priority: p));
Navigator.pop(context);
},
),
),
],
),
);
}
}
class _TaskFilterChip extends StatelessWidget {
final String label;
final bool isActive;
final IconData? icon;
final VoidCallback onTap;
const _TaskFilterChip({
required this.label,
required this.isActive,
required this.onTap,
this.icon,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isActive ? AppColors.primary100 : AppColors.gray100,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isActive ? AppColors.primary400 : Colors.transparent,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(
icon,
size: 13,
color: isActive ? AppColors.primary700 : AppColors.textSecondary,
),
const SizedBox(width: 5),
],
Text(
label,
style: AppTypography.caption.copyWith(
color: isActive ? AppColors.primary700 : AppColors.textSecondary,
fontWeight: isActive ? FontWeight.w600 : null,
),
),
const SizedBox(width: 3),
Icon(
LucideIcons.chevronDown,
size: 13,
color: isActive ? AppColors.primary700 : AppColors.textTertiary,
),
],
),
),
);
}
}
class _TaskFilterOption {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _TaskFilterOption({
required this.label,
required this.isSelected,
required this.onTap,
});
}
class _TaskFilterSheet extends StatelessWidget {
final String title;
final List<_TaskFilterOption> options;
const _TaskFilterSheet({required this.title, required this.options});
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 8),
child: Row(
children: [
Text(title, style: AppTypography.h3),
const Spacer(),
IconButton(
icon: const Icon(LucideIcons.x, size: 20),
onPressed: () => Navigator.pop(context),
),
],
),
),
const Divider(height: 1),
...options.map(
(opt) => ListTile(
title: Text(opt.label),
trailing: opt.isSelected
? Icon(LucideIcons.check, color: AppColors.primary600)
: null,
onTap: opt.onTap,
),
),
],
),
),
);
}
}

View file

@ -6,6 +6,7 @@ import 'package:jwt_decoder/jwt_decoder.dart';
import '../config/api_config.dart';
import '../data/models/auth_response.dart';
import 'api_service.dart';
import 'crash_reporting.dart';
/// Authentication service for BottleCRM
///
@ -243,6 +244,7 @@ class AuthService {
}
debugPrint('AuthService: Magic code sign-in successful');
await CrashReporting.applyFromAuth(this);
return true;
} catch (e, stack) {
debugPrint('AuthService: signInWithMagicCode error: $e');
@ -291,6 +293,8 @@ class AuthService {
'AuthService: Auth response handled, user: ${_currentUser?.email}',
);
debugPrint('AuthService: Organizations: ${_organizations?.length ?? 0}');
await CrashReporting.applyFromAuth(this);
}
/// Refresh the access token
@ -368,6 +372,7 @@ class AuthService {
await _saveSelectedOrganization();
debugPrint('AuthService: Switched to organization: ${org.name}');
await CrashReporting.applyFromAuth(this);
return true;
} catch (e) {
debugPrint('AuthService: Switch org error: $e');
@ -406,6 +411,7 @@ class AuthService {
// it here used to break refresh for any signOut → signIn cycle inside
// the same app session (no path re-registers it).
await _clearStorage();
await CrashReporting.clear();
debugPrint('AuthService: Signed out');
}

View file

@ -0,0 +1,40 @@
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart';
import 'auth_service.dart';
/// Thin wrapper around Crashlytics so the rest of the app doesn't depend on
/// `firebase_*` directly. Every method is a no-op when Firebase isn't running
/// (debug iOS builds, tests, etc.), so callers can fire-and-forget.
class CrashReporting {
static bool get _enabled {
if (kIsWeb) return false;
if (defaultTargetPlatform != TargetPlatform.android) return false;
return Firebase.apps.isNotEmpty;
}
/// Reflect the current auth state into Crashlytics — user id + email + org.
/// Call after sign-in, org switch, or restore-from-storage.
static Future<void> applyFromAuth(AuthService auth) async {
if (!_enabled) return;
final user = auth.currentUser;
final org = auth.selectedOrganization;
final c = FirebaseCrashlytics.instance;
await c.setUserIdentifier(user?.id ?? '');
await c.setCustomKey('user_email', user?.email ?? '');
await c.setCustomKey('org_id', org?.id ?? '');
await c.setCustomKey('org_name', org?.name ?? '');
}
/// Clear identity on sign-out so subsequent crashes aren't attributed to the
/// previous user.
static Future<void> clear() async {
if (!_enabled) return;
final c = FirebaseCrashlytics.instance;
await c.setUserIdentifier('');
await c.setCustomKey('user_email', '');
await c.setCustomKey('org_id', '');
await c.setCustomKey('org_name', '');
}
}

View file

@ -5,12 +5,17 @@ import '../../data/models/models.dart';
import '../common/common.dart';
/// Task Row Widget
/// Displays task in list with checkbox, info, and swipe actions
/// Displays task in list with checkbox, info, status/priority badges, and
/// horizontal swipe actions (right → complete, left → delete).
class TaskRow extends StatelessWidget {
final Task task;
final VoidCallback? onToggle;
final VoidCallback? onTap;
final VoidCallback? onDelete;
// Optional fast-path for swipe-right (mark complete). Falls back to
// [onToggle] when omitted. Kept separate so callers can disable it for
// already-completed tasks where "complete again" makes no sense.
final VoidCallback? onComplete;
const TaskRow({
super.key,
@ -18,22 +23,45 @@ class TaskRow extends StatelessWidget {
this.onToggle,
this.onTap,
this.onDelete,
this.onComplete,
});
@override
Widget build(BuildContext context) {
final canComplete = !task.completed && (onComplete != null || onToggle != null);
return Dismissible(
key: Key(task.id),
direction: DismissDirection.endToStart,
background: Container(
direction: canComplete
? DismissDirection.horizontal
: DismissDirection.endToStart,
background: canComplete
? Container(
color: AppColors.success500,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: 20),
child: const Icon(
LucideIcons.check,
color: Colors.white,
size: 22,
),
)
: null,
secondaryBackground: Container(
color: AppColors.danger500,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(LucideIcons.trash2, color: Colors.white, size: 22),
),
confirmDismiss: (direction) async {
onDelete?.call();
return false; // Handle deletion in callback
if (direction == DismissDirection.startToEnd) {
(onComplete ?? onToggle)?.call();
} else if (direction == DismissDirection.endToStart) {
onDelete?.call();
}
// Both actions are handled by the callback (which may show a
// confirmation dialog); never let the Dismissible self-remove the row
// so the provider stays the source of truth for the visible list.
return false;
},
child: InkWell(
onTap: onTap,
@ -45,7 +73,6 @@ class TaskRow extends StatelessWidget {
),
child: Row(
children: [
// Checkbox
GestureDetector(
onTap: onToggle,
child: AnimatedContainer(
@ -79,7 +106,6 @@ class TaskRow extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
Text(
task.title,
style: AppTypography.label.copyWith(
@ -135,18 +161,34 @@ class TaskRow extends StatelessWidget {
],
],
),
// Status chip + tag chips, only rendered when there's
// something to show. Keeps the row visually unchanged for
// tasks that have nothing tagged.
if (_shouldShowMetaRow()) ...[
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
if (!task.completed && task.status != TaskStatus.newTask)
_StatusChip(status: task.status),
...task.tags.take(3).map((t) => _TagChip(label: t)),
if (task.tags.length > 3)
_TagChip(label: '+${task.tags.length - 3}'),
],
),
],
],
),
),
const SizedBox(width: 10),
// Priority Badge
PriorityBadge.fromPriority(task.priority, compact: true),
const SizedBox(width: 10),
// Assignee Avatar
UserAvatar(name: task.assignedToName, size: AvatarSize.xs),
],
),
@ -155,6 +197,12 @@ class TaskRow extends StatelessWidget {
);
}
bool _shouldShowMetaRow() {
final hasStatusToShow =
!task.completed && task.status != TaskStatus.newTask;
return hasStatusToShow || task.tags.isNotEmpty;
}
bool _isUrgent() {
if (task.dueDate == null) return false;
final now = DateTime.now();
@ -248,6 +296,56 @@ class TaskRow extends StatelessWidget {
}
}
class _StatusChip extends StatelessWidget {
final TaskStatus status;
const _StatusChip({required this.status});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: status.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: status.color.withValues(alpha: 0.4)),
),
child: Text(
status.label,
style: AppTypography.caption.copyWith(
color: status.color,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
);
}
}
class _TagChip extends StatelessWidget {
final String label;
const _TagChip({required this.label});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.gray100,
borderRadius: BorderRadius.circular(4),
),
child: Text(
label,
style: AppTypography.caption.copyWith(
color: AppColors.textSecondary,
fontSize: 10,
),
),
);
}
}
/// Task Group Widget
/// Collapsible section for grouping tasks (Overdue, Today, Upcoming)
class TaskGroup extends StatefulWidget {
@ -257,6 +355,7 @@ class TaskGroup extends StatefulWidget {
final Function(Task)? onToggle;
final Function(Task)? onTap;
final Function(Task)? onDelete;
final Function(Task)? onComplete;
final bool initiallyExpanded;
const TaskGroup({
@ -267,6 +366,7 @@ class TaskGroup extends StatefulWidget {
this.onToggle,
this.onTap,
this.onDelete,
this.onComplete,
this.initiallyExpanded = true,
});
@ -300,7 +400,6 @@ class _TaskGroupState extends State<TaskGroup> {
return Column(
children: [
// Group Header
InkWell(
onTap: () => setState(() => _expanded = !_expanded),
child: Container(
@ -354,7 +453,6 @@ class _TaskGroupState extends State<TaskGroup> {
),
),
// Group Content
AnimatedCrossFade(
firstChild: Column(
children: widget.tasks.map((task) {
@ -363,6 +461,9 @@ class _TaskGroupState extends State<TaskGroup> {
onToggle: () => widget.onToggle?.call(task),
onTap: () => widget.onTap?.call(task),
onDelete: () => widget.onDelete?.call(task),
onComplete: task.completed
? null
: () => (widget.onComplete ?? widget.onToggle)?.call(task),
);
}).toList(),
),

View file

@ -9,6 +9,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "93.0.0"
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11
url: "https://pub.dev"
source: hosted
version: "1.3.59"
analyzer:
dependency: transitive
description:
@ -177,6 +185,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
firebase_core:
dependency: "direct main"
description:
name: firebase_core
sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5"
url: "https://pub.dev"
source: hosted
version: "3.15.2"
firebase_core_platform_interface:
dependency: transitive
description:
name: firebase_core_platform_interface
sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce"
url: "https://pub.dev"
source: hosted
version: "6.0.3"
firebase_core_web:
dependency: transitive
description:
name: firebase_core_web
sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37"
url: "https://pub.dev"
source: hosted
version: "2.24.1"
firebase_crashlytics:
dependency: "direct main"
description:
name: firebase_crashlytics
sha256: "662ae6443da91bca1fb0be8aeeac026fa2975e8b7ddfca36e4d90ebafa35dde1"
url: "https://pub.dev"
source: hosted
version: "4.3.10"
firebase_crashlytics_platform_interface:
dependency: transitive
description:
name: firebase_crashlytics_platform_interface
sha256: "7222a8a40077c79f6b8b3f3439241c9f2b34e9ddfde8381ffc512f7b2e61f7eb"
url: "https://pub.dev"
source: hosted
version: "3.8.10"
fl_chart:
dependency: "direct main"
description:

View file

@ -18,6 +18,10 @@ dependencies:
shared_preferences: ^2.5.5
jwt_decoder: ^2.0.1
package_info_plus: ^10.1.0
# Firebase / Crashlytics (Android only — no iOS GoogleService-Info.plist yet)
firebase_core: ^3.10.0
firebase_crashlytics: ^4.2.0
# Icons
cupertino_icons: ^1.0.9
lucide_icons: ^0.257.0