From eaab9972acd046ed6d3daae5486e18a6c03909ea Mon Sep 17 00:00:00 2001 From: ashwin Date: Sun, 17 May 2026 02:09:09 +0530 Subject: [PATCH] feat: integrate Firebase Crashlytics, add dynamic task relation labels, and update task model fields --- backend/tasks/serializer.py | 74 +- backend/tasks/views/task_views.py | 88 +- mobile/android/app/build.gradle.kts | 1 + mobile/android/build.gradle.kts | 3 +- mobile/lib/data/models/dashboard_data.dart | 26 +- mobile/lib/data/models/task.dart | 46 + mobile/lib/firebase_options.dart | 32 + mobile/lib/main.dart | 72 +- mobile/lib/providers/tasks_provider.dart | 269 +++++- .../lib/screens/leads/lead_detail_screen.dart | 149 +-- .../lib/screens/tasks/task_detail_screen.dart | 857 +++++++++++++++--- .../lib/screens/tasks/task_form_screen.dart | 49 +- .../lib/screens/tasks/tasks_list_screen.dart | 519 ++++++++++- mobile/lib/services/auth_service.dart | 6 + mobile/lib/services/crash_reporting.dart | 40 + mobile/lib/widgets/cards/task_row.dart | 123 ++- mobile/pubspec.lock | 48 + mobile/pubspec.yaml | 4 + 18 files changed, 2061 insertions(+), 345 deletions(-) create mode 100644 mobile/lib/firebase_options.dart create mode 100644 mobile/lib/services/crash_reporting.dart diff --git a/backend/tasks/serializer.py b/backend/tasks/serializer.py index cab6e26b..b8a0d524 100644 --- a/backend/tasks/serializer.py +++ b/backend/tasks/serializer.py @@ -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) diff --git a/backend/tasks/views/task_views.py b/backend/tasks/views/task_views.py index b638bdad..03522087 100644 --- a/backend/tasks/views/task_views.py +++ b/backend/tasks/views/task_views.py @@ -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, diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index eb9832e7..9264d302 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -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 { // ... diff --git a/mobile/android/build.gradle.kts b/mobile/android/build.gradle.kts index a58274b2..64e782dc 100644 --- a/mobile/android/build.gradle.kts +++ b/mobile/android/build.gradle.kts @@ -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") } } diff --git a/mobile/lib/data/models/dashboard_data.dart b/mobile/lib/data/models/dashboard_data.dart index 0fdd7947..fa8e77d0 100644 --- a/mobile/lib/data/models/dashboard_data.dart +++ b/mobile/lib/data/models/dashboard_data.dart @@ -145,6 +145,25 @@ class DashboardTask { String? get relatedTo => accountName ?? leadName; factory DashboardTask.fromJson(Map 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) { + accountName = account['name'] as String?; + } + + String? leadName; + final lead = json['lead']; + if (lead is Map) { + 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, ); } } diff --git a/mobile/lib/data/models/task.dart b/mobile/lib/data/models/task.dart index 21ca9b40..e7ae781a 100644 --- a/mobile/lib/data/models/task.dart +++ b/mobile/lib/data/models/task.dart @@ -104,7 +104,15 @@ class Task { 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; @@ -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.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? ?? '', @@ -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? tags, + List? teamNames, Map? 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, ); diff --git a/mobile/lib/firebase_options.dart b/mobile/lib/firebase_options.dart new file mode 100644 index 00000000..93eae730 --- /dev/null +++ b/mobile/lib/firebase_options.dart @@ -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', + ); +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index e6534d34..0ed00a5a 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -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>( + () 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 diff --git a/mobile/lib/providers/tasks_provider.dart b/mobile/lib/providers/tasks_provider.dart index 6f3fcdf4..c5edcd73 100644 --- a/mobile/lib/providers/tasks_provider.dart +++ b/mobile/lib/providers/tasks_provider.dart @@ -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 comments; + final List attachments; + final List customFieldDefinitions; + + const TaskDetailResult({ + required this.task, + this.comments = const [], + this.attachments = const [], + this.customFieldDefinitions = const [], + }); + + TaskDetailResult copyWith({ + Task? task, + List? comments, + List? attachments, + List? 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 tasks; @@ -40,41 +151,32 @@ class TasksNotifier extends AsyncNotifier { final ApiService _apiService = ApiService(); static const int _pageSize = 20; + TaskFilters _filters = const TaskFilters(); + TaskFilters get filters => _filters; + @override Future build() => _fetchPage(offset: 0); - Future 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 setFilters(TaskFilters filters) async { + _filters = filters; + await refresh(); } - Future loadMore({ - String? search, - String? status, - String? priority, - }) async { + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() => _fetchPage(offset: 0)); + } + + Future 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 { }); } - Future _fetchPage({ - required int offset, - String? search, - String? status, - String? priority, - }) async { + Future _fetchPage({required int offset}) async { final queryParams = { '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 { /// Fetch a single task from the API. Future 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 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?; + if (taskData == null) return null; - if (response.success && response.data != null) { - final taskData = response.data!['task_obj'] as Map?; - if (taskData != null) { - return Task.fromJson(taskData); + final comments = []; + for (final c in (data['comments'] as List? ?? [])) { + if (c is Map) comments.add(Comment.fromJson(c)); + } + final attachments = []; + for (final a in (data['attachments'] as List? ?? [])) { + if (a is Map) attachments.add(Attachment.fromJson(a)); + } + final defs = []; + for (final d in (data['custom_field_definitions'] as List? ?? [])) { + if (d is Map) { + 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>> 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>> 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>> 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>> createTask( Map data, ) async { @@ -182,7 +362,12 @@ class TasksNotifier extends AsyncNotifier { ) 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) { diff --git a/mobile/lib/screens/leads/lead_detail_screen.dart b/mobile/lib/screens/leads/lead_detail_screen.dart index e5efecbe..074a3bb0 100644 --- a/mobile/lib/screens/leads/lead_detail_screen.dart +++ b/mobile/lib/screens/leads/lead_detail_screen.dart @@ -1092,31 +1092,48 @@ class _LeadDetailScreenState extends ConsumerState .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(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 _editComment(String commentId, String currentText) async { @@ -1134,16 +1151,7 @@ class _LeadDetailScreenState extends ConsumerState .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 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 _deleteComment(String commentId) async { @@ -1159,28 +1194,34 @@ class _LeadDetailScreenState extends ConsumerState .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({ diff --git a/mobile/lib/screens/tasks/task_detail_screen.dart b/mobile/lib/screens/tasks/task_detail_screen.dart index 0434d9a6..7ddbf6c3 100644 --- a/mobile/lib/screens/tasks/task_detail_screen.dart +++ b/mobile/lib/screens/tasks/task_detail_screen.dart @@ -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 { - 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 _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 { 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 { ), ], flexibleSpace: FlexibleSpaceBar(background: _buildHeader()), - ), - - // Content - SliverToBoxAdapter(child: _buildContent()), - ], - ), - bottomNavigationBar: _buildBottomActions(), - ); + ); } Color _getHeaderColor() { @@ -297,111 +329,109 @@ class _TaskDetailScreenState extends ConsumerState { } Widget _buildContent() { + final task = _task!; + final related = _resolveRelatedEntity(); + final defs = _detail?.customFieldDefinitions ?? const []; + final comments = _detail?.comments ?? const []; + final attachments = _detail?.attachments ?? const []; + 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 { 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 { ); } + /// 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> 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 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) { + 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 _buildCustomFieldRows(List defs) { + final rows = []; + 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 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 { 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 { } } + Future _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(Comment.fromJson) + .toList(); + final newAttachments = attachmentsData is List + ? attachmentsData + .whereType>() + .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 _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 { 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, + }); +} diff --git a/mobile/lib/screens/tasks/task_form_screen.dart b/mobile/lib/screens/tasks/task_form_screen.dart index 607e3ff1..184e9fdb 100644 --- a/mobile/lib/screens/tasks/task_form_screen.dart +++ b/mobile/lib/screens/tasks/task_form_screen.dart @@ -52,7 +52,6 @@ class _TaskFormScreenState extends ConsumerState { List _assigneeIds = []; _RelatedKind? _relatedKind; String? _relatedId; - String? _relatedLabel; Map _customFields = {}; bool _isLoading = false; bool _isFetchingTask = false; @@ -121,7 +120,43 @@ class _TaskFormScreenState extends ConsumerState { _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 { 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 { } 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 { : () => setState(() { _relatedKind = null; _relatedId = null; - _relatedLabel = null; }), child: displayValue == null ? null @@ -856,7 +892,6 @@ class _TaskFormScreenState extends ConsumerState { setState(() { _relatedKind = kind; _relatedId = pick.id; - _relatedLabel = pick.name; }); } break; @@ -872,11 +907,9 @@ class _TaskFormScreenState extends ConsumerState { 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 { setState(() { _relatedKind = kind; _relatedId = pick.id; - _relatedLabel = pick.title; }); } break; @@ -908,7 +940,6 @@ class _TaskFormScreenState extends ConsumerState { setState(() { _relatedKind = kind; _relatedId = pick.id; - _relatedLabel = pick.name; }); } break; diff --git a/mobile/lib/screens/tasks/tasks_list_screen.dart b/mobile/lib/screens/tasks/tasks_list_screen.dart index dcd21835..15ffd5db 100644 --- a/mobile/lib/screens/tasks/tasks_list_screen.dart +++ b/mobile/lib/screens/tasks/tasks_list_screen.dart @@ -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 { 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 _getTaskDates(List tasks) { @@ -47,9 +107,39 @@ class _TasksListScreenState extends ConsumerState { }); } + /// 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 []; final isLoading = tasksAsync.isLoading; final error = tasksAsync.error?.toString(); @@ -62,7 +152,6 @@ class _TasksListScreenState extends ConsumerState { elevation: 0, scrolledUnderElevation: 1, actions: [ - // View toggle IconButton( icon: Icon( _viewMode == TaskViewMode.calendar @@ -70,33 +159,31 @@ class _TasksListScreenState extends ConsumerState { : 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 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 { ); } + 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 = []; + 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 allTasks) { final taskDates = _getTaskDates(allTasks); final tasksForSelectedDate = _getTasksForSelectedDate(allTasks); @@ -170,9 +394,12 @@ class _TasksListScreenState extends ConsumerState { }, 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 { color: AppColors.primary700, fontWeight: FontWeight.w600, ), - // Selected selectedDecoration: BoxDecoration( color: AppColors.primary600, shape: BoxShape.circle, @@ -190,13 +416,11 @@ class _TasksListScreenState extends ConsumerState { 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 { onToggle: () => _toggleTask(task), onTap: () => _showTaskDetail(task), onDelete: () => _deleteTask(task), + onComplete: task.completed ? null : () => _toggleTask(task), ); }, ), @@ -332,15 +557,24 @@ class _TasksListScreenState extends ConsumerState { 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 { 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 { 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 { 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 { 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 { 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 { } Widget _buildAllCaughtUpState() { + final isFiltered = _filters.isActive; return Center( child: Padding( padding: const EdgeInsets.all(32), @@ -466,19 +701,21 @@ class _TasksListScreenState extends ConsumerState { 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 { ), 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 { Future _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 { } void _showTaskDetail(Task task) { - // Navigate to full detail screen context.push('/tasks/${task.id}'); } @@ -619,4 +854,224 @@ class _TasksListScreenState extends ConsumerState { 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, + ), + ), + ], + ), + ), + ); + } } diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index 9310abb9..9775816f 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -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'); } diff --git a/mobile/lib/services/crash_reporting.dart b/mobile/lib/services/crash_reporting.dart new file mode 100644 index 00000000..f6cdd8dd --- /dev/null +++ b/mobile/lib/services/crash_reporting.dart @@ -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 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 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', ''); + } +} diff --git a/mobile/lib/widgets/cards/task_row.dart b/mobile/lib/widgets/cards/task_row.dart index 36199353..b5b494ea 100644 --- a/mobile/lib/widgets/cards/task_row.dart +++ b/mobile/lib/widgets/cards/task_row.dart @@ -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 { return Column( children: [ - // Group Header InkWell( onTap: () => setState(() => _expanded = !_expanded), child: Container( @@ -354,7 +453,6 @@ class _TaskGroupState extends State { ), ), - // Group Content AnimatedCrossFade( firstChild: Column( children: widget.tasks.map((task) { @@ -363,6 +461,9 @@ class _TaskGroupState extends State { 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(), ), diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 485c5d82..1e77dfab 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -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: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index e9b46dfa..61b31e89 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -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