feat: integrate Firebase Crashlytics, add dynamic task relation labels, and update task model fields
This commit is contained in:
parent
acf38437bd
commit
eaab9972ac
18 changed files with 2061 additions and 345 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
// ...
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -145,6 +145,25 @@ class DashboardTask {
|
|||
String? get relatedTo => accountName ?? leadName;
|
||||
|
||||
factory DashboardTask.fromJson(Map<String, dynamic> json) {
|
||||
// Backend's TaskSerializer returns `account`/`lead` as plain FK UUIDs
|
||||
// (strings), not nested {id, name} objects. Only extract a display name
|
||||
// when the field is a Map — otherwise we have a UUID with no name and
|
||||
// skip the related-entity badge rather than crashing on String indexing.
|
||||
String? accountName;
|
||||
final account = json['account'];
|
||||
if (account is Map<String, dynamic>) {
|
||||
accountName = account['name'] as String?;
|
||||
}
|
||||
|
||||
String? leadName;
|
||||
final lead = json['lead'];
|
||||
if (lead is Map<String, dynamic>) {
|
||||
final first = (lead['first_name'] as String?) ?? '';
|
||||
final last = (lead['last_name'] as String?) ?? '';
|
||||
final joined = '$first $last'.trim();
|
||||
if (joined.isNotEmpty) leadName = joined;
|
||||
}
|
||||
|
||||
return DashboardTask(
|
||||
id: json['id']?.toString() ?? '',
|
||||
title: json['title'] as String? ?? 'Untitled',
|
||||
|
|
@ -154,11 +173,8 @@ class DashboardTask {
|
|||
dueDate: json['due_date'] != null
|
||||
? DateTime.tryParse(json['due_date'] as String)
|
||||
: null,
|
||||
accountName: json['account']?['name'] as String?,
|
||||
leadName: json['lead'] != null
|
||||
? '${json['lead']['first_name'] ?? ''} ${json['lead']['last_name'] ?? ''}'
|
||||
.trim()
|
||||
: null,
|
||||
accountName: accountName,
|
||||
leadName: leadName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,7 +104,15 @@ class Task {
|
|||
final String? caseId;
|
||||
final String? leadId;
|
||||
final List<String> tags;
|
||||
// Resolved team names attached to the task (TaskSerializer returns nested
|
||||
// Team objects, but only the display name is used in the UI today).
|
||||
final List<String> teamNames;
|
||||
final Map<String, dynamic> customFields;
|
||||
// Task creator — backend serializes via UserSerializer, exposing at least
|
||||
// an email. We surface both first/last name (joined) and email so the UI
|
||||
// can pick whichever is non-empty.
|
||||
final String? createdByName;
|
||||
final String? createdByEmail;
|
||||
final DateTime createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
|
|
@ -123,7 +131,10 @@ class Task {
|
|||
this.caseId,
|
||||
this.leadId,
|
||||
this.tags = const [],
|
||||
this.teamNames = const [],
|
||||
this.customFields = const {},
|
||||
this.createdByName,
|
||||
this.createdByEmail,
|
||||
required this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
|
@ -265,6 +276,32 @@ class Task {
|
|||
? Map<String, dynamic>.from(rawCustomFields)
|
||||
: const {};
|
||||
|
||||
// Teams — TaskSerializer returns nested objects. Fall back to a string
|
||||
// if the API ever switches to plain values.
|
||||
final List<String> parsedTeamNames = [];
|
||||
if (json['teams'] is List) {
|
||||
for (final t in json['teams'] as List<dynamic>) {
|
||||
if (t is Map<String, dynamic>) {
|
||||
final name = t['name'] as String?;
|
||||
if (name != null && name.isNotEmpty) parsedTeamNames.add(name);
|
||||
} else if (t is String && t.isNotEmpty) {
|
||||
parsedTeamNames.add(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// created_by — UserSerializer shape: {id, email, name, profile_pic}.
|
||||
// Be defensive: backend serializers sometimes return null or a plain id,
|
||||
// and `name` may be an empty string for users who never set it.
|
||||
String? createdByName;
|
||||
String? createdByEmail;
|
||||
final cb = json['created_by'];
|
||||
if (cb is Map<String, dynamic>) {
|
||||
createdByEmail = cb['email'] as String?;
|
||||
final name = (cb['name'] as String?)?.trim();
|
||||
if (name != null && name.isNotEmpty) createdByName = name;
|
||||
}
|
||||
|
||||
return Task(
|
||||
id: json['id']?.toString() ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
|
|
@ -282,7 +319,10 @@ class Task {
|
|||
caseId: caseId,
|
||||
leadId: leadId,
|
||||
tags: parsedTags,
|
||||
teamNames: parsedTeamNames,
|
||||
customFields: parsedCustomFields,
|
||||
createdByName: createdByName,
|
||||
createdByEmail: createdByEmail,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.tryParse(json['created_at'] as String) ?? DateTime.now()
|
||||
: DateTime.now(),
|
||||
|
|
@ -319,7 +359,10 @@ class Task {
|
|||
String? caseId,
|
||||
String? leadId,
|
||||
List<String>? tags,
|
||||
List<String>? teamNames,
|
||||
Map<String, dynamic>? customFields,
|
||||
String? createdByName,
|
||||
String? createdByEmail,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
|
|
@ -338,7 +381,10 @@ class Task {
|
|||
caseId: caseId ?? this.caseId,
|
||||
leadId: leadId ?? this.leadId,
|
||||
tags: tags ?? this.tags,
|
||||
teamNames: teamNames ?? this.teamNames,
|
||||
customFields: customFields ?? this.customFields,
|
||||
createdByName: createdByName ?? this.createdByName,
|
||||
createdByEmail: createdByEmail ?? this.createdByEmail,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
|
|
|
|||
32
mobile/lib/firebase_options.dart
Normal file
32
mobile/lib/firebase_options.dart
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Hand-written Firebase config (not from `flutterfire configure`) — Android
|
||||
// is the only configured platform. Values mirror `android/app/google-services.json`.
|
||||
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||
import 'package:flutter/foundation.dart'
|
||||
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||
|
||||
class DefaultFirebaseOptions {
|
||||
static FirebaseOptions get currentPlatform {
|
||||
if (kIsWeb) {
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions: web is not configured for this app.',
|
||||
);
|
||||
}
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
return android;
|
||||
default:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions: ${defaultTargetPlatform.name} '
|
||||
'is not configured for this app.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static const FirebaseOptions android = FirebaseOptions(
|
||||
apiKey: 'AIzaSyDsA2wgQIw-bVgwODNMIF482gVRn5xdANU',
|
||||
appId: '1:1072513761792:android:c170ae72445200ecd74ee5',
|
||||
messagingSenderId: '1072513761792',
|
||||
projectId: 'bottlecrm-io',
|
||||
storageBucket: 'bottlecrm-io.firebasestorage.app',
|
||||
);
|
||||
}
|
||||
|
|
@ -1,33 +1,67 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'core/theme/theme.dart';
|
||||
import 'firebase_options.dart';
|
||||
import 'routes/app_router.dart';
|
||||
import 'services/auth_service.dart';
|
||||
import 'services/crash_reporting.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
runZonedGuarded<Future<void>>(
|
||||
() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize services
|
||||
await AuthService().initialize();
|
||||
// Firebase + Crashlytics — Android only. iOS has no GoogleService-Info.plist
|
||||
// yet, so skip init there to avoid a startup crash.
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
await Firebase.initializeApp(
|
||||
options: DefaultFirebaseOptions.currentPlatform,
|
||||
);
|
||||
// Off in debug to avoid spamming the Crashlytics console with dev noise.
|
||||
await FirebaseCrashlytics.instance
|
||||
.setCrashlyticsCollectionEnabled(!kDebugMode);
|
||||
FlutterError.onError =
|
||||
FirebaseCrashlytics.instance.recordFlutterFatalError;
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
// Set system UI overlay style
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
systemNavigationBarColor: AppColors.surface,
|
||||
systemNavigationBarIconBrightness: Brightness.dark,
|
||||
),
|
||||
await AuthService().initialize();
|
||||
// Tag any already-signed-in session so the first uncaught error has a user.
|
||||
await CrashReporting.applyFromAuth(AuthService());
|
||||
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
systemNavigationBarColor: AppColors.surface,
|
||||
systemNavigationBarIconBrightness: Brightness.dark,
|
||||
),
|
||||
);
|
||||
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
|
||||
runApp(const ProviderScope(child: BottleCRMApp()));
|
||||
},
|
||||
(error, stack) {
|
||||
// Catch-all for anything that escaped the zone — e.g. errors from
|
||||
// microtasks scheduled before PlatformDispatcher.onError was wired.
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Lock to portrait mode for optimal CRM experience
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
|
||||
runApp(const ProviderScope(child: BottleCRMApp()));
|
||||
}
|
||||
|
||||
/// BottleCRM - Main Application Widget
|
||||
|
|
|
|||
|
|
@ -1,12 +1,123 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../config/api_config.dart';
|
||||
import '../data/models/attachment.dart';
|
||||
import '../data/models/comment.dart';
|
||||
import '../data/models/custom_field_definition.dart';
|
||||
import '../data/models/lead.dart' show Priority;
|
||||
import '../data/models/task.dart';
|
||||
import '../services/api_service.dart';
|
||||
|
||||
export '../services/api_service.dart' show ApiResponse;
|
||||
|
||||
/// Full task-detail snapshot returned by `GET /api/tasks/{id}/`.
|
||||
/// Bundles everything the detail screen needs in one round-trip so we don't
|
||||
/// chase the same endpoint twice for comments / attachments / CF schema.
|
||||
class TaskDetailResult {
|
||||
final Task task;
|
||||
final List<Comment> comments;
|
||||
final List<Attachment> attachments;
|
||||
final List<CustomFieldDefinition> customFieldDefinitions;
|
||||
|
||||
const TaskDetailResult({
|
||||
required this.task,
|
||||
this.comments = const [],
|
||||
this.attachments = const [],
|
||||
this.customFieldDefinitions = const [],
|
||||
});
|
||||
|
||||
TaskDetailResult copyWith({
|
||||
Task? task,
|
||||
List<Comment>? comments,
|
||||
List<Attachment>? attachments,
|
||||
List<CustomFieldDefinition>? customFieldDefinitions,
|
||||
}) {
|
||||
return TaskDetailResult(
|
||||
task: task ?? this.task,
|
||||
comments: comments ?? this.comments,
|
||||
attachments: attachments ?? this.attachments,
|
||||
customFieldDefinitions:
|
||||
customFieldDefinitions ?? this.customFieldDefinitions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Server-side filter set for the tasks list. Held as an immutable value so
|
||||
/// the screen can diff "did the user actually change something" before
|
||||
/// triggering a refetch.
|
||||
class TaskFilters {
|
||||
final String? search;
|
||||
final TaskStatus? status;
|
||||
final Priority? priority;
|
||||
// Assignee profile id. null = anyone (no filter).
|
||||
final String? assignedToId;
|
||||
// Display label for the active assignee filter (e.g. "Me"). Not sent to
|
||||
// backend; used by the UI to render the chip without a profile lookup.
|
||||
final String? assignedToLabel;
|
||||
// ISO date strings (yyyy-MM-dd) — backend supports due_date__gte/__lte.
|
||||
// Used by the calendar view to fetch only the visible month.
|
||||
final String? dueDateGte;
|
||||
final String? dueDateLte;
|
||||
|
||||
const TaskFilters({
|
||||
this.search,
|
||||
this.status,
|
||||
this.priority,
|
||||
this.assignedToId,
|
||||
this.assignedToLabel,
|
||||
this.dueDateGte,
|
||||
this.dueDateLte,
|
||||
});
|
||||
|
||||
bool get isEmpty =>
|
||||
(search == null || search!.isEmpty) &&
|
||||
status == null &&
|
||||
priority == null &&
|
||||
(assignedToId == null || assignedToId!.isEmpty);
|
||||
|
||||
bool get isActive => !isEmpty;
|
||||
|
||||
TaskFilters copyWith({
|
||||
String? search,
|
||||
TaskStatus? status,
|
||||
Priority? priority,
|
||||
String? assignedToId,
|
||||
String? assignedToLabel,
|
||||
String? dueDateGte,
|
||||
String? dueDateLte,
|
||||
}) {
|
||||
return TaskFilters(
|
||||
search: search ?? this.search,
|
||||
status: status ?? this.status,
|
||||
priority: priority ?? this.priority,
|
||||
assignedToId: assignedToId ?? this.assignedToId,
|
||||
assignedToLabel: assignedToLabel ?? this.assignedToLabel,
|
||||
dueDateGte: dueDateGte ?? this.dueDateGte,
|
||||
dueDateLte: dueDateLte ?? this.dueDateLte,
|
||||
);
|
||||
}
|
||||
|
||||
/// copyWith can't distinguish "unchanged" from "set to null", so use this
|
||||
/// to explicitly clear individual fields.
|
||||
TaskFilters cleared({
|
||||
bool search = false,
|
||||
bool status = false,
|
||||
bool priority = false,
|
||||
bool assignedTo = false,
|
||||
bool dueDate = false,
|
||||
}) {
|
||||
return TaskFilters(
|
||||
search: search ? null : this.search,
|
||||
status: status ? null : this.status,
|
||||
priority: priority ? null : this.priority,
|
||||
assignedToId: assignedTo ? null : assignedToId,
|
||||
assignedToLabel: assignedTo ? null : assignedToLabel,
|
||||
dueDateGte: dueDate ? null : dueDateGte,
|
||||
dueDateLte: dueDate ? null : dueDateLte,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Paginated tasks snapshot — wrapped by AsyncValue.
|
||||
class TasksListData {
|
||||
final List<Task> tasks;
|
||||
|
|
@ -40,41 +151,32 @@ class TasksNotifier extends AsyncNotifier<TasksListData> {
|
|||
final ApiService _apiService = ApiService();
|
||||
static const int _pageSize = 20;
|
||||
|
||||
TaskFilters _filters = const TaskFilters();
|
||||
TaskFilters get filters => _filters;
|
||||
|
||||
@override
|
||||
Future<TasksListData> build() => _fetchPage(offset: 0);
|
||||
|
||||
Future<void> refresh({
|
||||
String? search,
|
||||
String? status,
|
||||
String? priority,
|
||||
}) async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(
|
||||
() => _fetchPage(
|
||||
offset: 0,
|
||||
search: search,
|
||||
status: status,
|
||||
priority: priority,
|
||||
),
|
||||
);
|
||||
/// Replace filters and refetch from offset 0. Use this from the screen
|
||||
/// when chips/search change so the in-flight queryParams stay consistent
|
||||
/// across `refresh()` and `loadMore()`.
|
||||
Future<void> setFilters(TaskFilters filters) async {
|
||||
_filters = filters;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> loadMore({
|
||||
String? search,
|
||||
String? status,
|
||||
String? priority,
|
||||
}) async {
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() => _fetchPage(offset: 0));
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
final current = state.value;
|
||||
if (current == null || !current.hasMore) return;
|
||||
if (state.isLoading) return;
|
||||
|
||||
state = await AsyncValue.guard(() async {
|
||||
final next = await _fetchPage(
|
||||
offset: current.currentOffset,
|
||||
search: search,
|
||||
status: status,
|
||||
priority: priority,
|
||||
);
|
||||
final next = await _fetchPage(offset: current.currentOffset);
|
||||
return current.copyWith(
|
||||
tasks: [...current.tasks, ...next.tasks],
|
||||
totalCount: next.totalCount,
|
||||
|
|
@ -84,20 +186,25 @@ class TasksNotifier extends AsyncNotifier<TasksListData> {
|
|||
});
|
||||
}
|
||||
|
||||
Future<TasksListData> _fetchPage({
|
||||
required int offset,
|
||||
String? search,
|
||||
String? status,
|
||||
String? priority,
|
||||
}) async {
|
||||
Future<TasksListData> _fetchPage({required int offset}) async {
|
||||
final queryParams = <String, String>{
|
||||
'limit': _pageSize.toString(),
|
||||
'offset': offset.toString(),
|
||||
};
|
||||
if (search != null && search.isNotEmpty) queryParams['search'] = search;
|
||||
if (status != null && status.isNotEmpty) queryParams['status'] = status;
|
||||
if (priority != null && priority.isNotEmpty) {
|
||||
queryParams['priority'] = priority;
|
||||
final f = _filters;
|
||||
if (f.search != null && f.search!.isNotEmpty) {
|
||||
queryParams['search'] = f.search!;
|
||||
}
|
||||
if (f.status != null) queryParams['status'] = f.status!.value;
|
||||
if (f.priority != null) queryParams['priority'] = f.priority!.label;
|
||||
if (f.assignedToId != null && f.assignedToId!.isNotEmpty) {
|
||||
queryParams['assigned_to'] = f.assignedToId!;
|
||||
}
|
||||
if (f.dueDateGte != null && f.dueDateGte!.isNotEmpty) {
|
||||
queryParams['due_date__gte'] = f.dueDateGte!;
|
||||
}
|
||||
if (f.dueDateLte != null && f.dueDateLte!.isNotEmpty) {
|
||||
queryParams['due_date__lte'] = f.dueDateLte!;
|
||||
}
|
||||
|
||||
final url = Uri.parse(
|
||||
|
|
@ -147,23 +254,96 @@ class TasksNotifier extends AsyncNotifier<TasksListData> {
|
|||
|
||||
/// Fetch a single task from the API.
|
||||
Future<Task?> getTaskById(String taskId) async {
|
||||
final detail = await getTaskDetail(taskId);
|
||||
return detail?.task;
|
||||
}
|
||||
|
||||
/// Fetch full task detail incl. comments, attachments and custom-field
|
||||
/// schema. The detail endpoint returns all of these in one payload — splitting
|
||||
/// them into separate models lets the UI render each section without
|
||||
/// re-parsing the raw map.
|
||||
Future<TaskDetailResult?> getTaskDetail(String taskId) async {
|
||||
try {
|
||||
final url = '${ApiConfig.tasks}$taskId/';
|
||||
final response = await _apiService.get(url);
|
||||
if (!response.success || response.data == null) {
|
||||
debugPrint('TasksNotifier: detail fetch failed - ${response.message}');
|
||||
return null;
|
||||
}
|
||||
final data = response.data!;
|
||||
final taskData = data['task_obj'] as Map<String, dynamic>?;
|
||||
if (taskData == null) return null;
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
final taskData = response.data!['task_obj'] as Map<String, dynamic>?;
|
||||
if (taskData != null) {
|
||||
return Task.fromJson(taskData);
|
||||
final comments = <Comment>[];
|
||||
for (final c in (data['comments'] as List<dynamic>? ?? [])) {
|
||||
if (c is Map<String, dynamic>) comments.add(Comment.fromJson(c));
|
||||
}
|
||||
final attachments = <Attachment>[];
|
||||
for (final a in (data['attachments'] as List<dynamic>? ?? [])) {
|
||||
if (a is Map<String, dynamic>) attachments.add(Attachment.fromJson(a));
|
||||
}
|
||||
final defs = <CustomFieldDefinition>[];
|
||||
for (final d in (data['custom_field_definitions'] as List<dynamic>? ?? [])) {
|
||||
if (d is Map<String, dynamic>) {
|
||||
defs.add(CustomFieldDefinition.fromJson(d));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
defs.sort((a, b) {
|
||||
final byOrder = a.displayOrder.compareTo(b.displayOrder);
|
||||
return byOrder != 0 ? byOrder : a.label.compareTo(b.label);
|
||||
});
|
||||
|
||||
return TaskDetailResult(
|
||||
task: Task.fromJson(taskData),
|
||||
comments: comments,
|
||||
attachments: attachments,
|
||||
customFieldDefinitions: defs,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('TasksNotifier: Exception getting task - $e');
|
||||
debugPrint('TasksNotifier: Exception getting task detail - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Post a new comment to a task. Backend uses the same POST as the
|
||||
/// attachment-upload endpoint (`TaskDetailView.post`), and echoes the
|
||||
/// refreshed `comments` + `attachments` arrays in the response.
|
||||
Future<ApiResponse<Map<String, dynamic>>> addTaskComment(
|
||||
String taskId,
|
||||
String comment,
|
||||
) async {
|
||||
try {
|
||||
final url = '${ApiConfig.tasks}$taskId/';
|
||||
return await _apiService.post(url, {'comment': comment});
|
||||
} catch (e) {
|
||||
return ApiResponse(success: false, message: e.toString(), statusCode: 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete one comment by its UUID. Routed via `/api/tasks/comment/{id}/`.
|
||||
Future<ApiResponse<Map<String, dynamic>>> deleteTaskComment(
|
||||
String commentId,
|
||||
) async {
|
||||
try {
|
||||
final url = '${ApiConfig.tasks}comment/$commentId/';
|
||||
return await _apiService.delete(url);
|
||||
} catch (e) {
|
||||
return ApiResponse(success: false, message: e.toString(), statusCode: 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete one attachment by its UUID. Routed via `/api/tasks/attachment/{id}/`.
|
||||
Future<ApiResponse<Map<String, dynamic>>> deleteTaskAttachment(
|
||||
String attachmentId,
|
||||
) async {
|
||||
try {
|
||||
final url = '${ApiConfig.tasks}attachment/$attachmentId/';
|
||||
return await _apiService.delete(url);
|
||||
} catch (e) {
|
||||
return ApiResponse(success: false, message: e.toString(), statusCode: 0);
|
||||
}
|
||||
}
|
||||
|
||||
Future<ApiResponse<Map<String, dynamic>>> createTask(
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
|
|
@ -182,7 +362,12 @@ class TasksNotifier extends AsyncNotifier<TasksListData> {
|
|||
) async {
|
||||
try {
|
||||
final url = '${ApiConfig.tasks}$taskId/';
|
||||
final response = await _apiService.put(url, data);
|
||||
// PATCH (not PUT) so M2M fields the form doesn't touch — `tags`,
|
||||
// `contacts`, `teams` — are left alone. The backend PUT handler
|
||||
// unconditionally clears those M2Ms before reading the request body
|
||||
// (tasks/views/task_views.py), so a mobile PUT silently wipes them.
|
||||
// PATCH only mutates keys present in the payload.
|
||||
final response = await _apiService.patch(url, data);
|
||||
if (response.success) await refresh();
|
||||
return response;
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -1092,31 +1092,48 @@ class _LeadDetailScreenState extends ConsumerState<LeadDetailScreen>
|
|||
.read(leadsProvider.notifier)
|
||||
.addComment(widget.leadId, text);
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _isAddingNote = false);
|
||||
if (!mounted) return;
|
||||
setState(() => _isAddingNote = false);
|
||||
|
||||
if (response.success) {
|
||||
_noteController.clear();
|
||||
// Refresh lead to get updated comments
|
||||
await _fetchLead();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Note added'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(response.message ?? 'Failed to add note'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: AppColors.danger600,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!response.success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(response.message ?? 'Failed to add note'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: AppColors.danger600,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_noteController.clear();
|
||||
|
||||
// The POST returns the updated comments list — use it directly instead
|
||||
// of refetching. The previous refetch path could silently fail (provider
|
||||
// catches and returns null on any parse error), leaving the new comment
|
||||
// invisible even though the server saved it.
|
||||
final commentsData = response.data?['comments'];
|
||||
if (_lead != null && commentsData is List) {
|
||||
final newComments = commentsData
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Comment.fromJson)
|
||||
.toList();
|
||||
setState(() {
|
||||
_lead = _lead!.copyWith(comments: newComments);
|
||||
});
|
||||
} else {
|
||||
// Response missing comments (shouldn't happen) — fall back to refetch
|
||||
// so the user doesn't end up staring at stale data.
|
||||
await _fetchLead();
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Note added'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _editComment(String commentId, String currentText) async {
|
||||
|
|
@ -1134,16 +1151,7 @@ class _LeadDetailScreenState extends ConsumerState<LeadDetailScreen>
|
|||
.updateComment(commentId, newText);
|
||||
|
||||
if (!mounted) return;
|
||||
if (response.success) {
|
||||
await _fetchLead();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Note updated'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (!response.success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(response.message ?? 'Failed to update note'),
|
||||
|
|
@ -1151,7 +1159,34 @@ class _LeadDetailScreenState extends ConsumerState<LeadDetailScreen>
|
|||
backgroundColor: AppColors.danger600,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// PATCH returns only {error, message} — patch the comment locally.
|
||||
if (_lead != null) {
|
||||
final updated = _lead!.comments.map((c) {
|
||||
return c.id == commentId
|
||||
? Comment(
|
||||
id: c.id,
|
||||
comment: newText,
|
||||
commentedOn: c.commentedOn,
|
||||
commentedById: c.commentedById,
|
||||
commentedByName: c.commentedByName,
|
||||
commentedByEmail: c.commentedByEmail,
|
||||
)
|
||||
: c;
|
||||
}).toList();
|
||||
setState(() {
|
||||
_lead = _lead!.copyWith(comments: updated);
|
||||
});
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Note updated'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteComment(String commentId) async {
|
||||
|
|
@ -1159,28 +1194,34 @@ class _LeadDetailScreenState extends ConsumerState<LeadDetailScreen>
|
|||
.read(leadsProvider.notifier)
|
||||
.deleteComment(commentId);
|
||||
|
||||
if (mounted) {
|
||||
if (response.success) {
|
||||
// Refresh lead to get updated comments
|
||||
await _fetchLead();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Note deleted'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(response.message ?? 'Failed to delete note'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: AppColors.danger600,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (!response.success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(response.message ?? 'Failed to delete note'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: AppColors.danger600,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// DELETE returns only {error, message} — remove locally.
|
||||
if (_lead != null) {
|
||||
final filtered = _lead!.comments
|
||||
.where((c) => c.id != commentId)
|
||||
.toList();
|
||||
setState(() {
|
||||
_lead = _lead!.copyWith(comments: filtered);
|
||||
});
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Note deleted'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCard({
|
||||
|
|
|
|||
|
|
@ -2,9 +2,17 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../core/theme/theme.dart';
|
||||
import '../../data/models/models.dart';
|
||||
import '../../data/models/attachment.dart';
|
||||
import '../../data/models/comment.dart';
|
||||
import '../../data/models/custom_field_definition.dart';
|
||||
import '../../providers/deals_provider.dart';
|
||||
import '../../providers/leads_provider.dart';
|
||||
import '../../providers/lookup_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../widgets/common/common.dart';
|
||||
|
||||
/// Task Detail Screen
|
||||
|
|
@ -19,32 +27,45 @@ class TaskDetailScreen extends ConsumerStatefulWidget {
|
|||
}
|
||||
|
||||
class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||
Task? _task;
|
||||
TaskDetailResult? _detail;
|
||||
bool _isLoading = true;
|
||||
bool _isUpdating = false;
|
||||
bool _isPostingComment = false;
|
||||
String? _error;
|
||||
|
||||
final TextEditingController _commentController = TextEditingController();
|
||||
final FocusNode _commentFocus = FocusNode();
|
||||
|
||||
Task? get _task => _detail?.task;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchTask();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_commentController.dispose();
|
||||
_commentFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _fetchTask() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final task = await ref
|
||||
final detail = await ref
|
||||
.read(tasksProvider.notifier)
|
||||
.getTaskById(widget.taskId);
|
||||
.getTaskDetail(widget.taskId);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_task = task;
|
||||
if (task == null) {
|
||||
_detail = detail;
|
||||
if (detail == null) {
|
||||
_error = 'Failed to load task';
|
||||
}
|
||||
});
|
||||
|
|
@ -99,10 +120,28 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
|||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// App Bar
|
||||
SliverAppBar(
|
||||
// Body is a Column so the composer + action button can live at the
|
||||
// bottom of the body proper, *outside* the sliver scroll view. Putting
|
||||
// a TextField inside a Scaffold.bottomNavigationBar slot didn't reliably
|
||||
// raise the soft keyboard on Android — this layout does.
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
_buildSliverAppBar(),
|
||||
SliverToBoxAdapter(child: _buildContent()),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildBottomActions(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSliverAppBar() {
|
||||
return SliverAppBar(
|
||||
expandedHeight: 200,
|
||||
pinned: true,
|
||||
backgroundColor: _getHeaderColor(),
|
||||
|
|
@ -161,14 +200,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
|||
),
|
||||
],
|
||||
flexibleSpace: FlexibleSpaceBar(background: _buildHeader()),
|
||||
),
|
||||
|
||||
// Content
|
||||
SliverToBoxAdapter(child: _buildContent()),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: _buildBottomActions(),
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
Color _getHeaderColor() {
|
||||
|
|
@ -297,111 +329,109 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
|||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
final task = _task!;
|
||||
final related = _resolveRelatedEntity();
|
||||
final defs = _detail?.customFieldDefinitions ?? const <CustomFieldDefinition>[];
|
||||
final comments = _detail?.comments ?? const <Comment>[];
|
||||
final attachments = _detail?.attachments ?? const <Attachment>[];
|
||||
final createdByLabel = task.createdByName?.isNotEmpty == true
|
||||
? task.createdByName!
|
||||
: (task.createdByEmail ?? '');
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Description Card
|
||||
if (_task!.description != null && _task!.description!.isNotEmpty)
|
||||
if (task.description != null && task.description!.isNotEmpty) ...[
|
||||
_buildCard(
|
||||
title: 'Description',
|
||||
icon: LucideIcons.fileText,
|
||||
child: Text(
|
||||
_task!.description!,
|
||||
task.description!,
|
||||
style: AppTypography.body.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_task!.description != null && _task!.description!.isNotEmpty)
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Related Entity Card
|
||||
if (_task!.relatedTo != null)
|
||||
_buildCard(
|
||||
title: 'Related To',
|
||||
icon: LucideIcons.link2,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary100,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
_task!.relatedTo!.type.icon,
|
||||
size: 20,
|
||||
color: AppColors.primary600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_task!.relatedTo!.type.label,
|
||||
style: AppTypography.caption.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_task!.relatedTo!.title,
|
||||
style: AppTypography.label,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 20,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (related != null) ...[
|
||||
_buildRelatedCard(related),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
if (_task!.relatedTo != null) const SizedBox(height: 16),
|
||||
|
||||
// Assigned To Card
|
||||
if (_task!.assignedTo != null && _task!.assignedTo!.isNotEmpty)
|
||||
if (task.assignedTo != null && task.assignedTo!.isNotEmpty) ...[
|
||||
_buildCard(
|
||||
title: 'Assigned To',
|
||||
icon: LucideIcons.user,
|
||||
child: Row(
|
||||
children: [
|
||||
UserAvatar(name: _task!.assignedToName, size: AvatarSize.md),
|
||||
const SizedBox(width: 12),
|
||||
Text(_task!.assignedToName, style: AppTypography.label),
|
||||
],
|
||||
child: _buildAssigneesList(task.assignedTo!),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
if (task.teamNames.isNotEmpty) ...[
|
||||
_buildCard(
|
||||
title: 'Teams',
|
||||
icon: LucideIcons.users,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: task.teamNames
|
||||
.map((name) => LabelPill(label: name))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
|
||||
if (_task!.assignedTo != null && _task!.assignedTo!.isNotEmpty)
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Tags Card
|
||||
if (_task!.tags.isNotEmpty)
|
||||
if (task.tags.isNotEmpty) ...[
|
||||
_buildCard(
|
||||
title: 'Tags',
|
||||
icon: LucideIcons.tag,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _task!.tags
|
||||
children: task.tags
|
||||
.map((tag) => LabelPill(label: tag))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
if (_task!.tags.isNotEmpty) const SizedBox(height: 16),
|
||||
if (defs.any((d) => _cfValueOf(d).isNotEmpty)) ...[
|
||||
_buildCard(
|
||||
title: 'Custom Fields',
|
||||
icon: LucideIcons.listChecks,
|
||||
child: Column(
|
||||
children: _buildCustomFieldRows(defs),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
_buildCommentsCard(comments),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (attachments.isNotEmpty) ...[
|
||||
_buildCard(
|
||||
title: 'Attachments',
|
||||
icon: LucideIcons.paperclip,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < attachments.length; i++) ...[
|
||||
if (i > 0) const Divider(height: 16),
|
||||
_buildAttachmentRow(attachments[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Metadata Card
|
||||
_buildCard(
|
||||
title: 'Details',
|
||||
icon: LucideIcons.info,
|
||||
|
|
@ -409,14 +439,22 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
|||
children: [
|
||||
_buildInfoRow(
|
||||
'Created',
|
||||
_formatDateTime(_task!.createdAt),
|
||||
_formatDateTime(task.createdAt),
|
||||
LucideIcons.plus,
|
||||
),
|
||||
if (_task!.updatedAt != null) ...[
|
||||
if (createdByLabel.isNotEmpty) ...[
|
||||
const Divider(height: 20),
|
||||
_buildInfoRow(
|
||||
'Created by',
|
||||
createdByLabel,
|
||||
LucideIcons.user,
|
||||
),
|
||||
],
|
||||
if (task.updatedAt != null) ...[
|
||||
const Divider(height: 20),
|
||||
_buildInfoRow(
|
||||
'Last Updated',
|
||||
_formatDateTime(_task!.updatedAt!),
|
||||
_formatDateTime(task.updatedAt!),
|
||||
LucideIcons.pencil,
|
||||
),
|
||||
],
|
||||
|
|
@ -430,6 +468,398 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
/// Resolve the linked parent entity (account / lead / opportunity / case)
|
||||
/// from the cached lookup providers. The backend serializes these FKs as
|
||||
/// plain UUID strings, not nested objects — so without this lookup the
|
||||
/// "Related To" card silently disappears even when a parent IS attached.
|
||||
_RelatedRef? _resolveRelatedEntity() {
|
||||
final t = _task;
|
||||
if (t == null) return null;
|
||||
|
||||
if (t.accountId != null) {
|
||||
String label = 'Selected account';
|
||||
for (final a in ref.watch(accountsProvider)) {
|
||||
if (a.id == t.accountId) {
|
||||
label = a.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// No /accounts/:id route on mobile yet — show the card, but don't fake
|
||||
// a chevron the user can't follow.
|
||||
return _RelatedRef(
|
||||
typeLabel: 'Account',
|
||||
icon: LucideIcons.building,
|
||||
label: label,
|
||||
path: null,
|
||||
);
|
||||
}
|
||||
if (t.leadId != null) {
|
||||
String label = 'Selected lead';
|
||||
for (final l in ref.watch(leadsListProvider)) {
|
||||
if (l.id == t.leadId) {
|
||||
final n = '${l.firstName} ${l.lastName}'.trim();
|
||||
label = n.isEmpty ? (l.email) : n;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return _RelatedRef(
|
||||
typeLabel: 'Lead',
|
||||
icon: LucideIcons.userPlus,
|
||||
label: label,
|
||||
path: '/leads/${t.leadId}',
|
||||
);
|
||||
}
|
||||
if (t.opportunityId != null) {
|
||||
String label = 'Selected deal';
|
||||
for (final d in ref.watch(dealsListProvider)) {
|
||||
if (d.id == t.opportunityId) {
|
||||
label = d.title;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return _RelatedRef(
|
||||
typeLabel: 'Opportunity',
|
||||
icon: LucideIcons.trendingUp,
|
||||
label: label,
|
||||
path: '/deals/${t.opportunityId}',
|
||||
);
|
||||
}
|
||||
if (t.caseId != null) {
|
||||
String label = 'Selected ticket';
|
||||
for (final c in ref.watch(ticketsListProvider)) {
|
||||
if (c.id == t.caseId) {
|
||||
label = c.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return _RelatedRef(
|
||||
typeLabel: 'Ticket',
|
||||
icon: LucideIcons.lifeBuoy,
|
||||
label: label,
|
||||
path: '/tickets/${t.caseId}',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Widget _buildRelatedCard(_RelatedRef r) {
|
||||
return _buildCard(
|
||||
title: 'Related To',
|
||||
icon: LucideIcons.link2,
|
||||
child: InkWell(
|
||||
onTap: r.path == null ? null : () => context.push(r.path!),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary100,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(r.icon, size: 20, color: AppColors.primary600),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
r.typeLabel,
|
||||
style: AppTypography.caption.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(r.label, style: AppTypography.label),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (r.path != null)
|
||||
Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 20,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAssigneesList(List<Map<String, dynamic>> assignees) {
|
||||
return Column(
|
||||
children: [
|
||||
for (var i = 0; i < assignees.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
UserAvatar(
|
||||
name: _assigneeDisplayName(assignees[i]),
|
||||
size: AvatarSize.sm,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_assigneeDisplayName(assignees[i]),
|
||||
style: AppTypography.label,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _assigneeDisplayName(Map<String, dynamic> profile) {
|
||||
// ProfileSerializer wraps the user under `user_details` ({email, name, …}).
|
||||
// Some older endpoints still return user__email at the top level — keep
|
||||
// that fallback so a payload shape change doesn't blank out the avatar.
|
||||
final details = profile['user_details'];
|
||||
String? email;
|
||||
String? name;
|
||||
if (details is Map<String, dynamic>) {
|
||||
email = details['email'] as String?;
|
||||
name = (details['name'] as String?)?.trim();
|
||||
}
|
||||
email ??= profile['user__email'] as String?;
|
||||
if (name != null && name.isNotEmpty) return name;
|
||||
if (email != null && email.isNotEmpty) return email.split('@').first;
|
||||
return 'Unassigned';
|
||||
}
|
||||
|
||||
/// Render the raw value stored under `custom_fields[def.key]` as a
|
||||
/// display string. Empty / missing values return '' so the caller can
|
||||
/// skip them.
|
||||
String _cfValueOf(CustomFieldDefinition def) {
|
||||
final raw = _task?.customFields[def.key];
|
||||
if (raw == null) return '';
|
||||
if (raw is bool) return raw ? 'Yes' : 'No';
|
||||
if (raw is List) return raw.join(', ');
|
||||
final s = raw.toString();
|
||||
return s;
|
||||
}
|
||||
|
||||
List<Widget> _buildCustomFieldRows(List<CustomFieldDefinition> defs) {
|
||||
final rows = <Widget>[];
|
||||
for (final def in defs) {
|
||||
final value = _cfValueOf(def);
|
||||
if (value.isEmpty) continue;
|
||||
if (rows.isNotEmpty) rows.add(const Divider(height: 20));
|
||||
rows.add(_buildInfoRow(def.label, value, LucideIcons.tag));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
Widget _buildCommentsCard(List<Comment> comments) {
|
||||
// The composer lives in the bottomNavigationBar (see _buildBottomActions);
|
||||
// putting it here too caused focus/keyboard issues when the sliver
|
||||
// viewport pushed it offscreen on tap.
|
||||
return _buildCard(
|
||||
title: 'Comments',
|
||||
icon: LucideIcons.messageCircle,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (comments.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text(
|
||||
'No comments yet. Use the box below to add one.',
|
||||
style: AppTypography.body.copyWith(
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
for (var i = 0; i < comments.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 10),
|
||||
_buildCommentTile(comments[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCommentTile(Comment comment) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.gray50,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
comment.comment,
|
||||
style: AppTypography.body.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
UserAvatar(name: comment.authorName, size: AvatarSize.xs),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
comment.authorName,
|
||||
style: AppTypography.caption.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatTimeAgo(comment.commentedOn),
|
||||
style: AppTypography.caption.copyWith(
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
icon: Icon(
|
||||
LucideIcons.trash2,
|
||||
size: 14,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
onPressed: () => _confirmDeleteComment(comment),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCommentComposer() {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _commentController,
|
||||
focusNode: _commentFocus,
|
||||
enabled: !_isPostingComment,
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textInputAction: TextInputAction.newline,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Add a comment…',
|
||||
hintStyle: AppTypography.body.copyWith(
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.gray100,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 10,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Material(
|
||||
color: _isPostingComment
|
||||
? AppColors.gray400
|
||||
: AppColors.primary600,
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: _isPostingComment ? null : _addComment,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: _isPostingComment
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(LucideIcons.send, color: Colors.white, size: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAttachmentRow(Attachment att) {
|
||||
return InkWell(
|
||||
onTap: att.filePath == null ? null : () => _openAttachment(att),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.file,
|
||||
size: 18,
|
||||
color: AppColors.primary600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
att.fileName,
|
||||
style: AppTypography.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (att.createdBy != null && att.createdBy!.isNotEmpty)
|
||||
Text(
|
||||
att.createdBy!,
|
||||
style: AppTypography.caption.copyWith(
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: Icon(
|
||||
LucideIcons.trash2,
|
||||
size: 16,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
onPressed: () => _confirmDeleteAttachment(att),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCard({
|
||||
required String title,
|
||||
required IconData icon,
|
||||
|
|
@ -487,37 +917,49 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
|||
|
||||
Widget _buildBottomActions() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border(top: BorderSide(color: AppColors.border)),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Row(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Toggle Status Button
|
||||
Expanded(
|
||||
child: _isUpdating
|
||||
? const Center(
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: _task!.completed
|
||||
? SecondaryButton(
|
||||
label: 'Reopen',
|
||||
icon: LucideIcons.rotateCcw,
|
||||
onPressed: _toggleTaskStatus,
|
||||
isFullWidth: true,
|
||||
)
|
||||
: PrimaryButton(
|
||||
label: 'Complete',
|
||||
icon: LucideIcons.checkCircle2,
|
||||
onPressed: _toggleTaskStatus,
|
||||
isFullWidth: true,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||
child: _buildCommentComposer(),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _isUpdating
|
||||
? const Center(
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: _task!.completed
|
||||
? SecondaryButton(
|
||||
label: 'Reopen',
|
||||
icon: LucideIcons.rotateCcw,
|
||||
onPressed: _toggleTaskStatus,
|
||||
isFullWidth: true,
|
||||
)
|
||||
: PrimaryButton(
|
||||
label: 'Complete',
|
||||
icon: LucideIcons.checkCircle2,
|
||||
onPressed: _toggleTaskStatus,
|
||||
isFullWidth: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -681,6 +1123,178 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _addComment() async {
|
||||
final text = _commentController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
setState(() => _isPostingComment = true);
|
||||
final response = await ref
|
||||
.read(tasksProvider.notifier)
|
||||
.addTaskComment(widget.taskId, text);
|
||||
if (!mounted) return;
|
||||
if (!response.success) {
|
||||
setState(() => _isPostingComment = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(response.message ?? 'Failed to add comment'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: AppColors.danger600,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
_commentController.clear();
|
||||
|
||||
// Use the POST response directly — it returns the refreshed comments and
|
||||
// attachments. The previous _fetchTask() refetch flashed the full-page
|
||||
// spinner (since _fetchTask always sets _isLoading=true) and would
|
||||
// silently fail if getTaskDetail returned null, leaving the new comment
|
||||
// invisible even though the server saved it.
|
||||
final data = response.data;
|
||||
final commentsData = data?['comments'];
|
||||
final attachmentsData = data?['attachments'];
|
||||
if (_detail != null && commentsData is List) {
|
||||
final newComments = commentsData
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Comment.fromJson)
|
||||
.toList();
|
||||
final newAttachments = attachmentsData is List
|
||||
? attachmentsData
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Attachment.fromJson)
|
||||
.toList()
|
||||
: _detail!.attachments;
|
||||
setState(() {
|
||||
_detail = _detail!.copyWith(
|
||||
comments: newComments,
|
||||
attachments: newAttachments,
|
||||
);
|
||||
_isPostingComment = false;
|
||||
});
|
||||
} else {
|
||||
// Response shape unexpected — fall back to refetch.
|
||||
await _fetchTask();
|
||||
if (mounted) setState(() => _isPostingComment = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _confirmDeleteComment(Comment comment) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete comment?'),
|
||||
content: const Text('This cannot be undone.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(dialogContext);
|
||||
final response = await ref
|
||||
.read(tasksProvider.notifier)
|
||||
.deleteTaskComment(comment.id);
|
||||
if (!mounted) return;
|
||||
if (response.success || response.statusCode == 204) {
|
||||
if (_detail != null) {
|
||||
setState(() {
|
||||
_detail = _detail!.copyWith(
|
||||
comments: _detail!.comments
|
||||
.where((c) => c.id != comment.id)
|
||||
.toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(response.message ?? 'Delete failed'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: AppColors.danger600,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text('Delete', style: TextStyle(color: AppColors.danger600)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDeleteAttachment(Attachment att) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete attachment?'),
|
||||
content: Text('Remove "${att.fileName}"?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(dialogContext);
|
||||
final response = await ref
|
||||
.read(tasksProvider.notifier)
|
||||
.deleteTaskAttachment(att.id);
|
||||
if (!mounted) return;
|
||||
if (response.success || response.statusCode == 204) {
|
||||
if (_detail != null) {
|
||||
setState(() {
|
||||
_detail = _detail!.copyWith(
|
||||
attachments: _detail!.attachments
|
||||
.where((a) => a.id != att.id)
|
||||
.toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(response.message ?? 'Delete failed'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: AppColors.danger600,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text('Delete', style: TextStyle(color: AppColors.danger600)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openAttachment(Attachment att) async {
|
||||
final path = att.filePath;
|
||||
if (path == null || path.isEmpty) return;
|
||||
final uri = Uri.tryParse(path);
|
||||
if (uri == null) return;
|
||||
final launched = await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
if (!launched && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Could not open attachment'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTimeAgo(DateTime dt) {
|
||||
final diff = DateTime.now().difference(dt);
|
||||
if (diff.inDays > 30) return '${(diff.inDays / 30).floor()}mo ago';
|
||||
if (diff.inDays > 0) return '${diff.inDays}d ago';
|
||||
if (diff.inHours > 0) return '${diff.inHours}h ago';
|
||||
if (diff.inMinutes > 0) return '${diff.inMinutes}m ago';
|
||||
return 'Just now';
|
||||
}
|
||||
|
||||
String _formatDueDate(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
|
|
@ -734,3 +1348,20 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
|||
return '${months[date.month - 1]} ${date.day}, ${date.year}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved parent-entity reference for the Related-To card. `path` is null
|
||||
/// when the mobile app has no detail screen for that entity type (e.g.
|
||||
/// accounts) — the card renders without a chevron in that case.
|
||||
class _RelatedRef {
|
||||
final String typeLabel;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String? path;
|
||||
|
||||
const _RelatedRef({
|
||||
required this.typeLabel,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.path,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||
List<String> _assigneeIds = [];
|
||||
_RelatedKind? _relatedKind;
|
||||
String? _relatedId;
|
||||
String? _relatedLabel;
|
||||
Map<String, dynamic> _customFields = {};
|
||||
bool _isLoading = false;
|
||||
bool _isFetchingTask = false;
|
||||
|
|
@ -121,7 +120,43 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||
_relatedKind = _RelatedKind.ticket;
|
||||
_relatedId = task.caseId;
|
||||
}
|
||||
_relatedLabel = task.relatedTo?.title;
|
||||
}
|
||||
|
||||
// Resolve the label for the currently-linked entity from cached lookup
|
||||
// providers. The TaskSerializer returns FKs as plain UUIDs (not nested
|
||||
// objects), so we have no embedded name on the edit fetch — we look it up
|
||||
// in the same provider used by the picker. Returns null if the cache
|
||||
// doesn't have it yet (provider not loaded); the UI falls back to a
|
||||
// generic "Selected" hint until the provider warms up.
|
||||
String? _resolveRelatedLabel() {
|
||||
final id = _relatedId;
|
||||
final kind = _relatedKind;
|
||||
if (id == null || kind == null) return null;
|
||||
switch (kind) {
|
||||
case _RelatedKind.account:
|
||||
for (final a in ref.watch(accountsProvider)) {
|
||||
if (a.id == id) return a.name;
|
||||
}
|
||||
return null;
|
||||
case _RelatedKind.lead:
|
||||
for (final l in ref.watch(leadsListProvider)) {
|
||||
if (l.id == id) {
|
||||
final n = '${l.firstName} ${l.lastName}'.trim();
|
||||
return n.isEmpty ? l.email : n;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
case _RelatedKind.opportunity:
|
||||
for (final d in ref.watch(dealsListProvider)) {
|
||||
if (d.id == id) return d.title;
|
||||
}
|
||||
return null;
|
||||
case _RelatedKind.ticket:
|
||||
for (final t in ref.watch(ticketsListProvider)) {
|
||||
if (t.id == id) return t.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool get _hasUnsavedChanges {
|
||||
|
|
@ -432,6 +467,7 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||
prefixIcon: LucideIcons.checkSquare,
|
||||
textInputAction: TextInputAction.next,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
maxLength: 200,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Task title is required';
|
||||
|
|
@ -765,9 +801,10 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||
}
|
||||
|
||||
Widget _buildRelatedField() {
|
||||
final label = _resolveRelatedLabel();
|
||||
final displayValue = _relatedKind == null
|
||||
? null
|
||||
: '${_relatedKind!.label}: ${_relatedLabel ?? "Selected"}';
|
||||
: '${_relatedKind!.label}: ${label ?? "Selected"}';
|
||||
return _PickerRow(
|
||||
label: 'Linked record',
|
||||
icon: _relatedKind?.icon ?? LucideIcons.link2,
|
||||
|
|
@ -778,7 +815,6 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||
: () => setState(() {
|
||||
_relatedKind = null;
|
||||
_relatedId = null;
|
||||
_relatedLabel = null;
|
||||
}),
|
||||
child: displayValue == null
|
||||
? null
|
||||
|
|
@ -856,7 +892,6 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||
setState(() {
|
||||
_relatedKind = kind;
|
||||
_relatedId = pick.id;
|
||||
_relatedLabel = pick.name;
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
|
@ -872,11 +907,9 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||
icon: LucideIcons.user,
|
||||
);
|
||||
if (pick != null) {
|
||||
final n = '${pick.firstName} ${pick.lastName}'.trim();
|
||||
setState(() {
|
||||
_relatedKind = kind;
|
||||
_relatedId = pick.id;
|
||||
_relatedLabel = n.isEmpty ? pick.email : n;
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
|
@ -892,7 +925,6 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||
setState(() {
|
||||
_relatedKind = kind;
|
||||
_relatedId = pick.id;
|
||||
_relatedLabel = pick.title;
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
|
@ -908,7 +940,6 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||
setState(() {
|
||||
_relatedKind = kind;
|
||||
_relatedId = pick.id;
|
||||
_relatedLabel = pick.name;
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
|
@ -5,6 +7,7 @@ import 'package:lucide_icons/lucide_icons.dart';
|
|||
import 'package:table_calendar/table_calendar.dart';
|
||||
import '../../core/theme/theme.dart';
|
||||
import '../../data/models/models.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
import '../../widgets/cards/task_row.dart';
|
||||
import '../../widgets/common/common.dart';
|
||||
|
|
@ -26,6 +29,63 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
DateTime _selectedDay = DateTime.now();
|
||||
CalendarFormat _calendarFormat = CalendarFormat.month;
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
Timer? _searchDebounce;
|
||||
TaskFilters _filters = const TaskFilters();
|
||||
|
||||
static const _searchDebounceMs = 350;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchDebounce?.cancel();
|
||||
_searchController.dispose();
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Trigger paginated fetch when the user scrolls near the bottom. Without
|
||||
/// this, the provider's loadMore() never runs and users silently see only
|
||||
/// the first page (20 tasks).
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 200) {
|
||||
ref.read(tasksProvider.notifier).loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
void _applyFilters(TaskFilters next) {
|
||||
setState(() => _filters = next);
|
||||
ref.read(tasksProvider.notifier).setFilters(next);
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
setState(() {}); // refresh clear-button visibility
|
||||
_searchDebounce?.cancel();
|
||||
_searchDebounce = Timer(
|
||||
const Duration(milliseconds: _searchDebounceMs),
|
||||
() => _applyFilters(_filters.copyWith(search: value.trim())),
|
||||
);
|
||||
}
|
||||
|
||||
void _clearSearch() {
|
||||
_searchController.clear();
|
||||
_searchDebounce?.cancel();
|
||||
_applyFilters(_filters.cleared(search: true));
|
||||
}
|
||||
|
||||
void _clearAllFilters() {
|
||||
_searchController.clear();
|
||||
_searchDebounce?.cancel();
|
||||
_applyFilters(const TaskFilters());
|
||||
}
|
||||
|
||||
// Get task dates for calendar markers
|
||||
Set<String> _getTaskDates(List<Task> tasks) {
|
||||
|
|
@ -47,9 +107,39 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
});
|
||||
}
|
||||
|
||||
/// Set due-date range to the visible month so calendar markers cover every
|
||||
/// task in that month — not just the 20 currently paged in.
|
||||
void _fetchMonthForCalendar(DateTime focusedDay) {
|
||||
final first = DateTime(focusedDay.year, focusedDay.month, 1);
|
||||
final last = DateTime(focusedDay.year, focusedDay.month + 1, 0);
|
||||
final next = _filters.copyWith(
|
||||
dueDateGte: _formatDateKey(first),
|
||||
dueDateLte: _formatDateKey(last),
|
||||
);
|
||||
_applyFilters(next);
|
||||
}
|
||||
|
||||
void _toggleViewMode() {
|
||||
setState(() {
|
||||
_viewMode = _viewMode == TaskViewMode.calendar
|
||||
? TaskViewMode.list
|
||||
: TaskViewMode.calendar;
|
||||
});
|
||||
if (_viewMode == TaskViewMode.calendar) {
|
||||
_fetchMonthForCalendar(_focusedDay);
|
||||
} else if (_filters.dueDateGte != null || _filters.dueDateLte != null) {
|
||||
// Drop the month window when returning to list view so the user sees
|
||||
// all their tasks again.
|
||||
_applyFilters(_filters.cleared(dueDate: true));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tasksAsync = ref.watch(tasksProvider);
|
||||
// Eagerly subscribe so the "My tasks" toggle can read profile.id without
|
||||
// waiting for a separate fetch when the user taps it.
|
||||
ref.watch(profileProvider);
|
||||
final allTasks = tasksAsync.value?.tasks ?? const <Task>[];
|
||||
final isLoading = tasksAsync.isLoading;
|
||||
final error = tasksAsync.error?.toString();
|
||||
|
|
@ -62,7 +152,6 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
elevation: 0,
|
||||
scrolledUnderElevation: 1,
|
||||
actions: [
|
||||
// View toggle
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_viewMode == TaskViewMode.calendar
|
||||
|
|
@ -70,33 +159,31 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
: LucideIcons.calendar,
|
||||
size: 22,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_viewMode = _viewMode == TaskViewMode.calendar
|
||||
? TaskViewMode.list
|
||||
: TaskViewMode.calendar;
|
||||
});
|
||||
},
|
||||
onPressed: _toggleViewMode,
|
||||
),
|
||||
|
||||
// Add task
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.plus, size: 22),
|
||||
onPressed: () => _navigateToCreateTask(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _buildBody(allTasks, isLoading, error),
|
||||
body: Column(
|
||||
children: [
|
||||
if (_viewMode == TaskViewMode.list) ...[
|
||||
_buildSearchBar(),
|
||||
_buildFilterBar(),
|
||||
],
|
||||
Expanded(child: _buildBody(allTasks, isLoading, error)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(List<Task> allTasks, bool isLoading, String? error) {
|
||||
// Show loading spinner for initial load
|
||||
if (isLoading && allTasks.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (error != null && allTasks.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
|
|
@ -141,6 +228,143 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchBar() {
|
||||
final hasQuery = _searchController.text.isNotEmpty;
|
||||
return Container(
|
||||
color: AppColors.surfaceDim,
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 12, 6),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: _onSearchChanged,
|
||||
style: AppTypography.body,
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search task title',
|
||||
hintStyle: AppTypography.body.copyWith(color: AppColors.textTertiary),
|
||||
prefixIcon: Icon(
|
||||
LucideIcons.search,
|
||||
color: AppColors.textTertiary,
|
||||
size: 18,
|
||||
),
|
||||
suffixIcon: hasQuery
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
LucideIcons.x,
|
||||
color: AppColors.textTertiary,
|
||||
size: 16,
|
||||
),
|
||||
onPressed: _clearSearch,
|
||||
)
|
||||
: null,
|
||||
filled: true,
|
||||
fillColor: AppColors.gray100,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: AppColors.primary500, width: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterBar() {
|
||||
return Container(
|
||||
color: AppColors.surface,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
_TaskFilterChip(
|
||||
label: _filters.assignedToLabel ?? 'Anyone',
|
||||
isActive: _filters.assignedToId != null,
|
||||
icon: LucideIcons.user,
|
||||
onTap: _showAssigneeFilter,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_TaskFilterChip(
|
||||
label: _filters.status?.label ?? 'Status',
|
||||
isActive: _filters.status != null,
|
||||
onTap: _showStatusFilter,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_TaskFilterChip(
|
||||
label: _filters.priority?.label ?? 'Priority',
|
||||
isActive: _filters.priority != null,
|
||||
onTap: _showPriorityFilter,
|
||||
),
|
||||
if (_filters.isActive) ...[
|
||||
const SizedBox(width: 6),
|
||||
GestureDetector(
|
||||
onTap: _clearAllFilters,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.danger100,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.x, size: 12, color: AppColors.danger600),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'Clear',
|
||||
style: AppTypography.caption.copyWith(
|
||||
color: AppColors.danger600,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Compact summary line so overdue/today counts are visible without scrolling
|
||||
/// past the Completed accordion.
|
||||
Widget _buildHeaderSummary(int overdue, int today, int upcoming, int total) {
|
||||
final parts = <String>[];
|
||||
if (overdue > 0) parts.add('$overdue overdue');
|
||||
if (today > 0) parts.add('$today today');
|
||||
if (upcoming > 0) parts.add('$upcoming upcoming');
|
||||
final summary = parts.isEmpty
|
||||
? '$total task${total == 1 ? '' : 's'}'
|
||||
: parts.join(' · ');
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: AppColors.surfaceDim,
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 12, 8),
|
||||
child: Text(
|
||||
summary,
|
||||
style: AppTypography.caption.copyWith(
|
||||
color: overdue > 0 ? AppColors.danger600 : AppColors.textSecondary,
|
||||
fontWeight: overdue > 0 ? FontWeight.w600 : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCalendarView(List<Task> allTasks) {
|
||||
final taskDates = _getTaskDates(allTasks);
|
||||
final tasksForSelectedDate = _getTasksForSelectedDate(allTasks);
|
||||
|
|
@ -170,9 +394,12 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
},
|
||||
onPageChanged: (focusedDay) {
|
||||
_focusedDay = focusedDay;
|
||||
// Refetch the new month so markers reflect what's actually due.
|
||||
// Without this, navigating past page 1 looks empty even when
|
||||
// tasks exist in that month.
|
||||
_fetchMonthForCalendar(focusedDay);
|
||||
},
|
||||
calendarStyle: CalendarStyle(
|
||||
// Today
|
||||
todayDecoration: BoxDecoration(
|
||||
color: AppColors.primary100,
|
||||
shape: BoxShape.circle,
|
||||
|
|
@ -181,7 +408,6 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
color: AppColors.primary700,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
// Selected
|
||||
selectedDecoration: BoxDecoration(
|
||||
color: AppColors.primary600,
|
||||
shape: BoxShape.circle,
|
||||
|
|
@ -190,13 +416,11 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
// Default
|
||||
defaultTextStyle: AppTypography.body,
|
||||
weekendTextStyle: AppTypography.body,
|
||||
outsideTextStyle: AppTypography.body.copyWith(
|
||||
color: AppColors.gray300,
|
||||
),
|
||||
// Markers
|
||||
markerDecoration: BoxDecoration(
|
||||
color: AppColors.primary500,
|
||||
shape: BoxShape.circle,
|
||||
|
|
@ -307,6 +531,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
onToggle: () => _toggleTask(task),
|
||||
onTap: () => _showTaskDetail(task),
|
||||
onDelete: () => _deleteTask(task),
|
||||
onComplete: task.completed ? null : () => _toggleTask(task),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -332,15 +557,24 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
return _buildAllCaughtUpState();
|
||||
}
|
||||
|
||||
final hasMore = ref.watch(tasksProvider).value?.hasMore ?? false;
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await ref.read(tasksProvider.notifier).refresh();
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.only(bottom: 100),
|
||||
child: Column(
|
||||
children: [
|
||||
// Overdue
|
||||
_buildHeaderSummary(
|
||||
overdueTasks.length,
|
||||
todayTasks.length,
|
||||
upcomingTasks.length,
|
||||
allTasks.length,
|
||||
),
|
||||
|
||||
if (overdueTasks.isNotEmpty)
|
||||
TaskGroup(
|
||||
title: 'Overdue',
|
||||
|
|
@ -349,9 +583,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
onToggle: _toggleTask,
|
||||
onTap: _showTaskDetail,
|
||||
onDelete: _deleteTask,
|
||||
onComplete: (t) => _toggleTask(t),
|
||||
),
|
||||
|
||||
// Today
|
||||
if (todayTasks.isNotEmpty)
|
||||
TaskGroup(
|
||||
title: 'Today',
|
||||
|
|
@ -360,9 +594,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
onToggle: _toggleTask,
|
||||
onTap: _showTaskDetail,
|
||||
onDelete: _deleteTask,
|
||||
onComplete: (t) => _toggleTask(t),
|
||||
),
|
||||
|
||||
// Upcoming
|
||||
if (upcomingTasks.isNotEmpty)
|
||||
TaskGroup(
|
||||
title: 'Upcoming',
|
||||
|
|
@ -371,9 +605,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
onToggle: _toggleTask,
|
||||
onTap: _showTaskDetail,
|
||||
onDelete: _deleteTask,
|
||||
onComplete: (t) => _toggleTask(t),
|
||||
),
|
||||
|
||||
// No Due Date
|
||||
if (noDueDateTasks.isNotEmpty)
|
||||
TaskGroup(
|
||||
title: 'No Due Date',
|
||||
|
|
@ -382,9 +616,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
onToggle: _toggleTask,
|
||||
onTap: _showTaskDetail,
|
||||
onDelete: _deleteTask,
|
||||
onComplete: (t) => _toggleTask(t),
|
||||
),
|
||||
|
||||
// Completed (collapsed by default)
|
||||
if (completedTasks.isNotEmpty)
|
||||
TaskGroup(
|
||||
title: 'Completed',
|
||||
|
|
@ -394,10 +628,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
onToggle: _toggleTask,
|
||||
onTap: _showTaskDetail,
|
||||
onDelete: _deleteTask,
|
||||
onComplete: (t) => _toggleTask(t),
|
||||
),
|
||||
|
||||
// Loading indicator at bottom
|
||||
if (isLoading && allTasks.isNotEmpty)
|
||||
if (hasMore && isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
|
|
@ -452,6 +686,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
}
|
||||
|
||||
Widget _buildAllCaughtUpState() {
|
||||
final isFiltered = _filters.isActive;
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
|
|
@ -466,19 +701,21 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.checkCircle2,
|
||||
isFiltered ? LucideIcons.search : LucideIcons.checkCircle2,
|
||||
size: 40,
|
||||
color: AppColors.success600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'All caught up!',
|
||||
isFiltered ? 'No matching tasks' : 'All caught up!',
|
||||
style: AppTypography.h2.copyWith(color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'No pending tasks. Create a new task to get started.',
|
||||
isFiltered
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'No pending tasks. Create a new task to get started.',
|
||||
style: AppTypography.body.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
|
|
@ -486,9 +723,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
),
|
||||
const SizedBox(height: 24),
|
||||
PrimaryButton(
|
||||
label: 'Add Task',
|
||||
icon: LucideIcons.plus,
|
||||
onPressed: () => _navigateToCreateTask(),
|
||||
label: isFiltered ? 'Clear filters' : 'Add Task',
|
||||
icon: isFiltered ? LucideIcons.x : LucideIcons.plus,
|
||||
onPressed: isFiltered ? _clearAllFilters : _navigateToCreateTask,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -530,7 +767,6 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
Future<void> _navigateToCreateTask() async {
|
||||
final result = await context.push('/tasks/create');
|
||||
if (result == true && mounted) {
|
||||
// Refresh after creating a task
|
||||
ref.read(tasksProvider.notifier).refresh();
|
||||
}
|
||||
}
|
||||
|
|
@ -563,7 +799,6 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
}
|
||||
|
||||
void _showTaskDetail(Task task) {
|
||||
// Navigate to full detail screen
|
||||
context.push('/tasks/${task.id}');
|
||||
}
|
||||
|
||||
|
|
@ -619,4 +854,224 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
String _formatDateKey(DateTime date) {
|
||||
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
// ─── Filter sheets ────────────────────────────────────────────────────────
|
||||
|
||||
void _showAssigneeFilter() {
|
||||
final profileId = ref.read(profileProvider).value?.id;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: AppColors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) => _TaskFilterSheet(
|
||||
title: 'Filter by Owner',
|
||||
options: [
|
||||
_TaskFilterOption(
|
||||
label: 'Anyone',
|
||||
isSelected: _filters.assignedToId == null,
|
||||
onTap: () {
|
||||
_applyFilters(_filters.cleared(assignedTo: true));
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
if (profileId != null)
|
||||
_TaskFilterOption(
|
||||
label: 'My tasks',
|
||||
isSelected: _filters.assignedToId == profileId,
|
||||
onTap: () {
|
||||
_applyFilters(
|
||||
_filters.copyWith(
|
||||
assignedToId: profileId,
|
||||
assignedToLabel: 'Me',
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showStatusFilter() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: AppColors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) => _TaskFilterSheet(
|
||||
title: 'Filter by Status',
|
||||
options: [
|
||||
_TaskFilterOption(
|
||||
label: 'All statuses',
|
||||
isSelected: _filters.status == null,
|
||||
onTap: () {
|
||||
_applyFilters(_filters.cleared(status: true));
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
...TaskStatus.values.map(
|
||||
(s) => _TaskFilterOption(
|
||||
label: s.label,
|
||||
isSelected: _filters.status == s,
|
||||
onTap: () {
|
||||
_applyFilters(_filters.copyWith(status: s));
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPriorityFilter() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: AppColors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) => _TaskFilterSheet(
|
||||
title: 'Filter by Priority',
|
||||
options: [
|
||||
_TaskFilterOption(
|
||||
label: 'All priorities',
|
||||
isSelected: _filters.priority == null,
|
||||
onTap: () {
|
||||
_applyFilters(_filters.cleared(priority: true));
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
// Task model only supports Low/Medium/High at the backend level.
|
||||
...Priority.values.where((p) => p != Priority.urgent).map(
|
||||
(p) => _TaskFilterOption(
|
||||
label: p.label,
|
||||
isSelected: _filters.priority == p,
|
||||
onTap: () {
|
||||
_applyFilters(_filters.copyWith(priority: p));
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TaskFilterChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isActive;
|
||||
final IconData? icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _TaskFilterChip({
|
||||
required this.label,
|
||||
required this.isActive,
|
||||
required this.onTap,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? AppColors.primary100 : AppColors.gray100,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isActive ? AppColors.primary400 : Colors.transparent,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
size: 13,
|
||||
color: isActive ? AppColors.primary700 : AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
],
|
||||
Text(
|
||||
label,
|
||||
style: AppTypography.caption.copyWith(
|
||||
color: isActive ? AppColors.primary700 : AppColors.textSecondary,
|
||||
fontWeight: isActive ? FontWeight.w600 : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Icon(
|
||||
LucideIcons.chevronDown,
|
||||
size: 13,
|
||||
color: isActive ? AppColors.primary700 : AppColors.textTertiary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TaskFilterOption {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _TaskFilterOption({
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
}
|
||||
|
||||
class _TaskFilterSheet extends StatelessWidget {
|
||||
final String title;
|
||||
final List<_TaskFilterOption> options;
|
||||
|
||||
const _TaskFilterSheet({required this.title, required this.options});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(title, style: AppTypography.h3),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.x, size: 20),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
...options.map(
|
||||
(opt) => ListTile(
|
||||
title: Text(opt.label),
|
||||
trailing: opt.isSelected
|
||||
? Icon(LucideIcons.check, color: AppColors.primary600)
|
||||
: null,
|
||||
onTap: opt.onTap,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
40
mobile/lib/services/crash_reporting.dart
Normal file
40
mobile/lib/services/crash_reporting.dart
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'auth_service.dart';
|
||||
|
||||
/// Thin wrapper around Crashlytics so the rest of the app doesn't depend on
|
||||
/// `firebase_*` directly. Every method is a no-op when Firebase isn't running
|
||||
/// (debug iOS builds, tests, etc.), so callers can fire-and-forget.
|
||||
class CrashReporting {
|
||||
static bool get _enabled {
|
||||
if (kIsWeb) return false;
|
||||
if (defaultTargetPlatform != TargetPlatform.android) return false;
|
||||
return Firebase.apps.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Reflect the current auth state into Crashlytics — user id + email + org.
|
||||
/// Call after sign-in, org switch, or restore-from-storage.
|
||||
static Future<void> applyFromAuth(AuthService auth) async {
|
||||
if (!_enabled) return;
|
||||
final user = auth.currentUser;
|
||||
final org = auth.selectedOrganization;
|
||||
final c = FirebaseCrashlytics.instance;
|
||||
await c.setUserIdentifier(user?.id ?? '');
|
||||
await c.setCustomKey('user_email', user?.email ?? '');
|
||||
await c.setCustomKey('org_id', org?.id ?? '');
|
||||
await c.setCustomKey('org_name', org?.name ?? '');
|
||||
}
|
||||
|
||||
/// Clear identity on sign-out so subsequent crashes aren't attributed to the
|
||||
/// previous user.
|
||||
static Future<void> clear() async {
|
||||
if (!_enabled) return;
|
||||
final c = FirebaseCrashlytics.instance;
|
||||
await c.setUserIdentifier('');
|
||||
await c.setCustomKey('user_email', '');
|
||||
await c.setCustomKey('org_id', '');
|
||||
await c.setCustomKey('org_name', '');
|
||||
}
|
||||
}
|
||||
|
|
@ -5,12 +5,17 @@ import '../../data/models/models.dart';
|
|||
import '../common/common.dart';
|
||||
|
||||
/// Task Row Widget
|
||||
/// Displays task in list with checkbox, info, and swipe actions
|
||||
/// Displays task in list with checkbox, info, status/priority badges, and
|
||||
/// horizontal swipe actions (right → complete, left → delete).
|
||||
class TaskRow extends StatelessWidget {
|
||||
final Task task;
|
||||
final VoidCallback? onToggle;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onDelete;
|
||||
// Optional fast-path for swipe-right (mark complete). Falls back to
|
||||
// [onToggle] when omitted. Kept separate so callers can disable it for
|
||||
// already-completed tasks where "complete again" makes no sense.
|
||||
final VoidCallback? onComplete;
|
||||
|
||||
const TaskRow({
|
||||
super.key,
|
||||
|
|
@ -18,22 +23,45 @@ class TaskRow extends StatelessWidget {
|
|||
this.onToggle,
|
||||
this.onTap,
|
||||
this.onDelete,
|
||||
this.onComplete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final canComplete = !task.completed && (onComplete != null || onToggle != null);
|
||||
return Dismissible(
|
||||
key: Key(task.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
direction: canComplete
|
||||
? DismissDirection.horizontal
|
||||
: DismissDirection.endToStart,
|
||||
background: canComplete
|
||||
? Container(
|
||||
color: AppColors.success500,
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.only(left: 20),
|
||||
child: const Icon(
|
||||
LucideIcons.check,
|
||||
color: Colors.white,
|
||||
size: 22,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
secondaryBackground: Container(
|
||||
color: AppColors.danger500,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: const Icon(LucideIcons.trash2, color: Colors.white, size: 22),
|
||||
),
|
||||
confirmDismiss: (direction) async {
|
||||
onDelete?.call();
|
||||
return false; // Handle deletion in callback
|
||||
if (direction == DismissDirection.startToEnd) {
|
||||
(onComplete ?? onToggle)?.call();
|
||||
} else if (direction == DismissDirection.endToStart) {
|
||||
onDelete?.call();
|
||||
}
|
||||
// Both actions are handled by the callback (which may show a
|
||||
// confirmation dialog); never let the Dismissible self-remove the row
|
||||
// so the provider stays the source of truth for the visible list.
|
||||
return false;
|
||||
},
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
|
|
@ -45,7 +73,6 @@ class TaskRow extends StatelessWidget {
|
|||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Checkbox
|
||||
GestureDetector(
|
||||
onTap: onToggle,
|
||||
child: AnimatedContainer(
|
||||
|
|
@ -79,7 +106,6 @@ class TaskRow extends StatelessWidget {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
task.title,
|
||||
style: AppTypography.label.copyWith(
|
||||
|
|
@ -135,18 +161,34 @@ class TaskRow extends StatelessWidget {
|
|||
],
|
||||
],
|
||||
),
|
||||
|
||||
// Status chip + tag chips, only rendered when there's
|
||||
// something to show. Keeps the row visually unchanged for
|
||||
// tasks that have nothing tagged.
|
||||
if (_shouldShowMetaRow()) ...[
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
if (!task.completed && task.status != TaskStatus.newTask)
|
||||
_StatusChip(status: task.status),
|
||||
...task.tags.take(3).map((t) => _TagChip(label: t)),
|
||||
if (task.tags.length > 3)
|
||||
_TagChip(label: '+${task.tags.length - 3}'),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 10),
|
||||
|
||||
// Priority Badge
|
||||
PriorityBadge.fromPriority(task.priority, compact: true),
|
||||
|
||||
const SizedBox(width: 10),
|
||||
|
||||
// Assignee Avatar
|
||||
UserAvatar(name: task.assignedToName, size: AvatarSize.xs),
|
||||
],
|
||||
),
|
||||
|
|
@ -155,6 +197,12 @@ class TaskRow extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
bool _shouldShowMetaRow() {
|
||||
final hasStatusToShow =
|
||||
!task.completed && task.status != TaskStatus.newTask;
|
||||
return hasStatusToShow || task.tags.isNotEmpty;
|
||||
}
|
||||
|
||||
bool _isUrgent() {
|
||||
if (task.dueDate == null) return false;
|
||||
final now = DateTime.now();
|
||||
|
|
@ -248,6 +296,56 @@ class TaskRow extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
final TaskStatus status;
|
||||
|
||||
const _StatusChip({required this.status});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: status.color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: status.color.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Text(
|
||||
status.label,
|
||||
style: AppTypography.caption.copyWith(
|
||||
color: status.color,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TagChip extends StatelessWidget {
|
||||
final String label;
|
||||
|
||||
const _TagChip({required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.gray100,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: AppTypography.caption.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Task Group Widget
|
||||
/// Collapsible section for grouping tasks (Overdue, Today, Upcoming)
|
||||
class TaskGroup extends StatefulWidget {
|
||||
|
|
@ -257,6 +355,7 @@ class TaskGroup extends StatefulWidget {
|
|||
final Function(Task)? onToggle;
|
||||
final Function(Task)? onTap;
|
||||
final Function(Task)? onDelete;
|
||||
final Function(Task)? onComplete;
|
||||
final bool initiallyExpanded;
|
||||
|
||||
const TaskGroup({
|
||||
|
|
@ -267,6 +366,7 @@ class TaskGroup extends StatefulWidget {
|
|||
this.onToggle,
|
||||
this.onTap,
|
||||
this.onDelete,
|
||||
this.onComplete,
|
||||
this.initiallyExpanded = true,
|
||||
});
|
||||
|
||||
|
|
@ -300,7 +400,6 @@ class _TaskGroupState extends State<TaskGroup> {
|
|||
|
||||
return Column(
|
||||
children: [
|
||||
// Group Header
|
||||
InkWell(
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
child: Container(
|
||||
|
|
@ -354,7 +453,6 @@ class _TaskGroupState extends State<TaskGroup> {
|
|||
),
|
||||
),
|
||||
|
||||
// Group Content
|
||||
AnimatedCrossFade(
|
||||
firstChild: Column(
|
||||
children: widget.tasks.map((task) {
|
||||
|
|
@ -363,6 +461,9 @@ class _TaskGroupState extends State<TaskGroup> {
|
|||
onToggle: () => widget.onToggle?.call(task),
|
||||
onTap: () => widget.onTap?.call(task),
|
||||
onDelete: () => widget.onDelete?.call(task),
|
||||
onComplete: task.completed
|
||||
? null
|
||||
: () => (widget.onComplete ?? widget.onToggle)?.call(task),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue