Project management bug fixes
This commit is contained in:
@@ -119,12 +119,12 @@ Future<List<ProjectTaskComment>> getComments(int taskId) async {
|
||||
Future<ProjectTaskComment> addComment({
|
||||
required int taskId,
|
||||
required String content,
|
||||
List<String>? imagesBase64,
|
||||
List<Map<String, String>>? attachments,
|
||||
}) async {
|
||||
final response = await _dio.post('/projects/tasks/$taskId/comments', data: {
|
||||
'content': content,
|
||||
if (imagesBase64 != null && imagesBase64.isNotEmpty)
|
||||
'imagesJson': jsonEncode(imagesBase64),
|
||||
if (attachments != null && attachments.isNotEmpty)
|
||||
'imagesJson': jsonEncode(attachments),
|
||||
});
|
||||
return ProjectTaskComment.fromJson(response.data);
|
||||
}
|
||||
|
||||
@@ -22,10 +22,25 @@ class ProjectTaskComment {
|
||||
if (json['images'] != null) {
|
||||
if (json['images'] is String) {
|
||||
try {
|
||||
parsedImages = List<String>.from(jsonDecode(json['images']));
|
||||
final decoded = jsonDecode(json['images']);
|
||||
if (decoded is List) {
|
||||
for (var item in decoded) {
|
||||
if (item is String) {
|
||||
parsedImages.add(item);
|
||||
} else if (item is Map) {
|
||||
parsedImages.add(jsonEncode(item)); // keep it as json string
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
} else if (json['images'] is List) {
|
||||
parsedImages = List<String>.from(json['images']);
|
||||
for (var item in json['images']) {
|
||||
if (item is String) {
|
||||
parsedImages.add(item);
|
||||
} else if (item is Map) {
|
||||
parsedImages.add(jsonEncode(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import '../../domain/project_task.dart';
|
||||
import '../../providers/project_provider.dart';
|
||||
import '../../../transactions/presentation/widgets/attachment_gallery_screen.dart';
|
||||
|
||||
class TaskDetailsSheet extends ConsumerStatefulWidget {
|
||||
final ProjectTask task;
|
||||
|
||||
const TaskDetailsSheet({super.key, required this.task});
|
||||
const TaskDetailsSheet({Key? key, required this.task}) : super(key: key);
|
||||
|
||||
@override
|
||||
ConsumerState<TaskDetailsSheet> createState() => _TaskDetailsSheetState();
|
||||
@@ -18,26 +23,65 @@ class TaskDetailsSheet extends ConsumerStatefulWidget {
|
||||
|
||||
class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
|
||||
final TextEditingController _commentController = TextEditingController();
|
||||
final List<String> _imagesBase64 = [];
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
final List<Map<String, String>> _attachments = [];
|
||||
bool _isSubmitting = false;
|
||||
String? _jwtToken;
|
||||
final Set<int> _downloadingIds = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadToken();
|
||||
}
|
||||
|
||||
Future<void> _loadToken() async {
|
||||
const storage = FlutterSecureStorage();
|
||||
_jwtToken = await storage.read(key: 'jwt_token');
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _pickImage() async {
|
||||
if (_imagesBase64.length >= 3) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Maximum 3 images allowed')));
|
||||
return;
|
||||
}
|
||||
final ImagePicker picker = ImagePicker();
|
||||
final XFile? image = await picker.pickImage(source: ImageSource.gallery, imageQuality: 50);
|
||||
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
|
||||
if (image != null) {
|
||||
final bytes = await image.readAsBytes();
|
||||
setState(() {
|
||||
_imagesBase64.add(base64Encode(bytes));
|
||||
_attachments.add({
|
||||
'fileName': image.name,
|
||||
'contentType': 'image/jpeg',
|
||||
'base64Content': base64Encode(bytes),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickFile() async {
|
||||
try {
|
||||
final file = await FilePicker.pickFile();
|
||||
if (file != null && file.path != null) {
|
||||
final f = File(file.path!);
|
||||
final bytes = await f.readAsBytes();
|
||||
final extension = file.path!.split('.').last.toLowerCase();
|
||||
String contentType = 'application/octet-stream';
|
||||
if (['pdf'].contains(extension)) contentType = 'application/pdf';
|
||||
else if (['doc', 'docx'].contains(extension)) contentType = 'application/msword';
|
||||
else if (['png', 'jpg', 'jpeg'].contains(extension)) contentType = 'image/jpeg';
|
||||
|
||||
setState(() {
|
||||
_attachments.add({
|
||||
'fileName': file.name,
|
||||
'contentType': contentType,
|
||||
'base64Content': base64Encode(bytes),
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error picking file: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submitComment() async {
|
||||
if (_commentController.text.trim().isEmpty && _imagesBase64.isEmpty) return;
|
||||
if (_commentController.text.trim().isEmpty && _attachments.isEmpty) return;
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
@@ -45,11 +89,11 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
|
||||
await repo.addComment(
|
||||
taskId: widget.task.id,
|
||||
content: _commentController.text.trim(),
|
||||
imagesBase64: _imagesBase64,
|
||||
attachments: _attachments,
|
||||
);
|
||||
_commentController.clear();
|
||||
setState(() {
|
||||
_imagesBase64.clear();
|
||||
_attachments.clear();
|
||||
});
|
||||
ref.invalidate(projectTaskCommentsProvider(widget.task.id));
|
||||
} catch (e) {
|
||||
@@ -61,6 +105,40 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _downloadAttachment(Map<String, dynamic> attMap, int commentId, int attIndex) async {
|
||||
final filePath = attMap['filePath'];
|
||||
final fileName = attMap['fileName'] ?? 'attachment';
|
||||
final contentType = attMap['contentType'] ?? 'application/octet-stream';
|
||||
if (filePath == null) return;
|
||||
|
||||
final uniqueId = commentId * 1000 + attIndex;
|
||||
setState(() => _downloadingIds.add(uniqueId));
|
||||
|
||||
try {
|
||||
final dir = await getTemporaryDirectory();
|
||||
final savePath = '${dir.path}/$fileName';
|
||||
final dio = Dio();
|
||||
if (_jwtToken != null) {
|
||||
dio.options.headers['Authorization'] = 'Bearer $_jwtToken';
|
||||
}
|
||||
final url = 'https://app.technobeesolutions.in/api/kifi/projects/tasks/attachments/download?filePath=$filePath&contentType=$contentType';
|
||||
await dio.download(url, savePath);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Downloaded $fileName')));
|
||||
await Share.shareXFiles([XFile(savePath)]);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error downloading: $e')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _downloadingIds.remove(uniqueId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final commentsAsync = ref.watch(projectTaskCommentsProvider(widget.task.id));
|
||||
@@ -99,30 +177,126 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
|
||||
spacing: 8,
|
||||
children: c.images.asMap().entries.map((entry) {
|
||||
final imgIndex = entry.key;
|
||||
final img = entry.value;
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
final allImages = c.images.map((i) => MemoryImage(base64Decode(i)) as ImageProvider).toList();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AttachmentGalleryScreen(
|
||||
images: allImages,
|
||||
initialIndex: imgIndex,
|
||||
final imgData = entry.value;
|
||||
|
||||
bool isOldBase64 = !imgData.startsWith('{');
|
||||
Map<String, dynamic>? parsedData;
|
||||
if (!isOldBase64) {
|
||||
try {
|
||||
parsedData = jsonDecode(imgData);
|
||||
} catch (_) {
|
||||
isOldBase64 = true;
|
||||
}
|
||||
}
|
||||
|
||||
final isImage = isOldBase64 || (parsedData?['contentType']?.startsWith('image/') ?? false);
|
||||
|
||||
if (isImage) {
|
||||
ImageProvider provider;
|
||||
if (isOldBase64 || parsedData?['base64Content'] != null) {
|
||||
final b64 = isOldBase64 ? imgData : parsedData!['base64Content'];
|
||||
provider = MemoryImage(base64Decode(b64));
|
||||
} else {
|
||||
final url = 'https://app.technobeesolutions.in/api/kifi/projects/tasks/attachments/download?filePath=${parsedData!['filePath']}&contentType=${parsedData['contentType']}';
|
||||
provider = _jwtToken != null
|
||||
? NetworkImage(url, headers: {'Authorization': 'Bearer $_jwtToken'})
|
||||
: const AssetImage('assets/images/placeholder.png') as ImageProvider;
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
// Collect all images for the gallery
|
||||
final allImages = c.images.where((i) {
|
||||
if (!i.startsWith('{')) return true;
|
||||
try {
|
||||
final map = jsonDecode(i);
|
||||
return map['contentType']?.startsWith('image/') ?? false;
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}).map((i) {
|
||||
if (!i.startsWith('{')) return MemoryImage(base64Decode(i)) as ImageProvider;
|
||||
final map = jsonDecode(i);
|
||||
if (map['base64Content'] != null) return MemoryImage(base64Decode(map['base64Content'])) as ImageProvider;
|
||||
final url = 'https://app.technobeesolutions.in/api/kifi/projects/tasks/attachments/download?filePath=${map['filePath']}&contentType=${map['contentType']}';
|
||||
return NetworkImage(url, headers: {'Authorization': 'Bearer $_jwtToken'}) as ImageProvider;
|
||||
}).toList();
|
||||
|
||||
// Find index of clicked image among images
|
||||
int realIndex = 0;
|
||||
for (int i=0; i<=imgIndex; i++) {
|
||||
final ii = c.images[i];
|
||||
bool img = !ii.startsWith('{');
|
||||
if (!img) {
|
||||
try {
|
||||
final m = jsonDecode(ii);
|
||||
img = m['contentType']?.startsWith('image/') ?? false;
|
||||
} catch(_) { img = true; }
|
||||
}
|
||||
if (img && i < imgIndex) realIndex++;
|
||||
}
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AttachmentGalleryScreen(
|
||||
images: allImages,
|
||||
initialIndex: realIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image(
|
||||
image: provider,
|
||||
width: 80,
|
||||
height: 80,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.memory(
|
||||
base64Decode(img),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Non-image file
|
||||
final fileName = parsedData?['fileName'] ?? 'Document';
|
||||
final uniqueId = c.id * 1000 + imgIndex;
|
||||
final isDownloading = _downloadingIds.contains(uniqueId);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (!isDownloading && parsedData != null) {
|
||||
_downloadAttachment(parsedData, c.id, imgIndex);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
fit: BoxFit.cover,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
isDownloading
|
||||
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(LucideIcons.fileText, color: Colors.blue),
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
fileName,
|
||||
style: const TextStyle(fontSize: 10),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
}).toList(),
|
||||
)
|
||||
]
|
||||
@@ -181,22 +355,39 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_imagesBase64.isNotEmpty)
|
||||
if (_attachments.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
children: _imagesBase64.asMap().entries.map((entry) {
|
||||
children: _attachments.asMap().entries.map((entry) {
|
||||
final att = entry.value;
|
||||
final isImg = att['contentType']?.startsWith('image/') ?? false;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.memory(
|
||||
base64Decode(entry.value),
|
||||
width: 60,
|
||||
height: 60,
|
||||
fit: BoxFit.cover,
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: isImg
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.memory(
|
||||
base64Decode(att['base64Content']!),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(LucideIcons.fileText, size: 24, color: Colors.blue),
|
||||
Text(att['fileName'] ?? '', style: const TextStyle(fontSize: 8), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
@@ -204,7 +395,7 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_imagesBase64.removeAt(entry.key);
|
||||
_attachments.removeAt(entry.key);
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
@@ -224,6 +415,10 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
|
||||
icon: const Icon(LucideIcons.image),
|
||||
onPressed: _pickImage,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.paperclip),
|
||||
onPressed: _pickFile,
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _commentController,
|
||||
|
||||
Reference in New Issue
Block a user