Project management bug fixes
This commit is contained in:
@@ -109,6 +109,16 @@ public class ProjectController {
|
|||||||
return projectService.addComment(taskId, userId, request.getContent(), request.getImagesJson());
|
return projectService.addComment(taskId, userId, request.getContent(), request.getImagesJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/tasks/attachments/download")
|
||||||
|
public Mono<org.springframework.http.ResponseEntity<byte[]>> downloadAttachment(
|
||||||
|
@RequestParam String filePath,
|
||||||
|
@RequestParam String contentType) {
|
||||||
|
return projectService.downloadAttachment(contentType, filePath)
|
||||||
|
.map(res -> org.springframework.http.ResponseEntity.ok()
|
||||||
|
.header(org.springframework.http.HttpHeaders.CONTENT_TYPE, contentType)
|
||||||
|
.body(java.util.Base64.getDecoder().decode(res.getBase64Content())));
|
||||||
|
}
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
static class CreateProjectRequest {
|
static class CreateProjectRequest {
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import reactor.core.publisher.Flux;
|
|||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -22,6 +26,8 @@ public class ProjectService {
|
|||||||
private final ProjectRepository projectRepository;
|
private final ProjectRepository projectRepository;
|
||||||
private final ProjectTaskRepository projectTaskRepository;
|
private final ProjectTaskRepository projectTaskRepository;
|
||||||
private final ProjectTaskCommentRepository projectTaskCommentRepository;
|
private final ProjectTaskCommentRepository projectTaskCommentRepository;
|
||||||
|
private final MinioServiceClient minioServiceClient;
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
public Flux<Project> getProjectsByUser(Long userId) {
|
public Flux<Project> getProjectsByUser(Long userId) {
|
||||||
return projectRepository.findByUserId(userId);
|
return projectRepository.findByUserId(userId);
|
||||||
@@ -108,10 +114,65 @@ public class ProjectService {
|
|||||||
comment.setTaskId(taskId);
|
comment.setTaskId(taskId);
|
||||||
comment.setUserId(userId);
|
comment.setUserId(userId);
|
||||||
comment.setContent(content);
|
comment.setContent(content);
|
||||||
if (imagesJson != null && !imagesJson.isEmpty()) {
|
|
||||||
comment.setImages(imagesJson);
|
|
||||||
}
|
|
||||||
comment.setCreatedAt(LocalDateTime.now());
|
comment.setCreatedAt(LocalDateTime.now());
|
||||||
return projectTaskCommentRepository.save(comment);
|
|
||||||
|
if (imagesJson == null || imagesJson.trim().isEmpty() || imagesJson.equals("[]")) {
|
||||||
|
return projectTaskCommentRepository.save(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<Map<String, String>> attachments = objectMapper.readValue(imagesJson, new TypeReference<List<Map<String, String>>>() {});
|
||||||
|
|
||||||
|
// Backward compatibility for old base64 array
|
||||||
|
if (!attachments.isEmpty() && !attachments.get(0).containsKey("base64Content") && !attachments.get(0).containsKey("filePath")) {
|
||||||
|
comment.setImages(imagesJson);
|
||||||
|
return projectTaskCommentRepository.save(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Flux.fromIterable(attachments)
|
||||||
|
.flatMap(att -> {
|
||||||
|
if (att.containsKey("filePath")) {
|
||||||
|
// Already uploaded
|
||||||
|
return Mono.just(att);
|
||||||
|
}
|
||||||
|
String fileName = att.get("fileName");
|
||||||
|
String contentType = att.get("contentType");
|
||||||
|
String base64Content = att.get("base64Content");
|
||||||
|
if (base64Content == null) return Mono.just(att);
|
||||||
|
|
||||||
|
String uniqueFileName = java.util.UUID.randomUUID().toString() + "_" + fileName;
|
||||||
|
String directoryPath = "tasks/" + taskId;
|
||||||
|
|
||||||
|
return minioServiceClient.uploadFile(directoryPath, contentType, uniqueFileName, base64Content)
|
||||||
|
.map(minioResponse -> {
|
||||||
|
Map<String, String> result = new java.util.HashMap<>();
|
||||||
|
result.put("fileName", fileName);
|
||||||
|
result.put("contentType", contentType);
|
||||||
|
if (minioResponse.isSuccess()) {
|
||||||
|
result.put("filePath", minioResponse.getFilePath());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.collectList()
|
||||||
|
.flatMap(uploadedAttachments -> {
|
||||||
|
try {
|
||||||
|
String newImagesJson = objectMapper.writeValueAsString(uploadedAttachments);
|
||||||
|
comment.setImages(newImagesJson);
|
||||||
|
return projectTaskCommentRepository.save(comment);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Mono.error(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Fallback for simple string array (from old app version)
|
||||||
|
comment.setImages(imagesJson);
|
||||||
|
return projectTaskCommentRepository.save(comment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<MinioServiceClient.MinioDownloadResponse> downloadAttachment(String contentType, String filePath) {
|
||||||
|
return minioServiceClient.downloadFile(contentType, filePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
PODS:
|
PODS:
|
||||||
|
- file_picker_darwin (1.0.0):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- flutter_image_compress_common (1.0.0):
|
- flutter_image_compress_common (1.0.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
@@ -101,6 +104,7 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
|
- file_picker_darwin (from `.symlinks/plugins/file_picker_darwin/darwin`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`)
|
- flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`)
|
||||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||||
@@ -133,6 +137,8 @@ SPEC REPOS:
|
|||||||
- SDWebImageWebPCoder
|
- SDWebImageWebPCoder
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
|
file_picker_darwin:
|
||||||
|
:path: ".symlinks/plugins/file_picker_darwin/darwin"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
flutter_image_compress_common:
|
flutter_image_compress_common:
|
||||||
@@ -159,6 +165,7 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
|
file_picker_darwin: 65c8ed1d70cc2ea0b81ae1840db93f8d9c051d19
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
flutter_image_compress_common: 11d5dcfb36f4ded92320bfa896261813a073240e
|
flutter_image_compress_common: 11d5dcfb36f4ded92320bfa896261813a073240e
|
||||||
flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214
|
flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ class DioClient {
|
|||||||
|
|
||||||
DioClient._internal()
|
DioClient._internal()
|
||||||
: dio = Dio(BaseOptions(
|
: dio = Dio(BaseOptions(
|
||||||
//baseUrl: 'https://app.technobeesolutions.in/api/kifi-v2',
|
baseUrl: 'https://app.technobeesolutions.in/api/kifi-v2',
|
||||||
baseUrl: 'http://192.168.0.104:8080/api/kifi-v2', // Local Mac IP for physical device testing
|
//baseUrl: 'http://192.168.0.104:8080/api/kifi-v2', // Local Mac IP for physical device testing
|
||||||
connectTimeout: const Duration(seconds: 10),
|
connectTimeout: const Duration(seconds: 10),
|
||||||
receiveTimeout: const Duration(seconds: 10),
|
receiveTimeout: const Duration(seconds: 10),
|
||||||
)),
|
)),
|
||||||
|
|||||||
@@ -119,12 +119,12 @@ Future<List<ProjectTaskComment>> getComments(int taskId) async {
|
|||||||
Future<ProjectTaskComment> addComment({
|
Future<ProjectTaskComment> addComment({
|
||||||
required int taskId,
|
required int taskId,
|
||||||
required String content,
|
required String content,
|
||||||
List<String>? imagesBase64,
|
List<Map<String, String>>? attachments,
|
||||||
}) async {
|
}) async {
|
||||||
final response = await _dio.post('/projects/tasks/$taskId/comments', data: {
|
final response = await _dio.post('/projects/tasks/$taskId/comments', data: {
|
||||||
'content': content,
|
'content': content,
|
||||||
if (imagesBase64 != null && imagesBase64.isNotEmpty)
|
if (attachments != null && attachments.isNotEmpty)
|
||||||
'imagesJson': jsonEncode(imagesBase64),
|
'imagesJson': jsonEncode(attachments),
|
||||||
});
|
});
|
||||||
return ProjectTaskComment.fromJson(response.data);
|
return ProjectTaskComment.fromJson(response.data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,25 @@ class ProjectTaskComment {
|
|||||||
if (json['images'] != null) {
|
if (json['images'] != null) {
|
||||||
if (json['images'] is String) {
|
if (json['images'] is String) {
|
||||||
try {
|
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 (_) {}
|
} catch (_) {}
|
||||||
} else if (json['images'] is List) {
|
} 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:convert';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:image_picker/image_picker.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 '../../domain/project_task.dart';
|
||||||
import '../../providers/project_provider.dart';
|
import '../../providers/project_provider.dart';
|
||||||
import '../../../transactions/presentation/widgets/attachment_gallery_screen.dart';
|
import '../../../transactions/presentation/widgets/attachment_gallery_screen.dart';
|
||||||
|
|
||||||
class TaskDetailsSheet extends ConsumerStatefulWidget {
|
class TaskDetailsSheet extends ConsumerStatefulWidget {
|
||||||
final ProjectTask task;
|
final ProjectTask task;
|
||||||
|
const TaskDetailsSheet({Key? key, required this.task}) : super(key: key);
|
||||||
const TaskDetailsSheet({super.key, required this.task});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<TaskDetailsSheet> createState() => _TaskDetailsSheetState();
|
ConsumerState<TaskDetailsSheet> createState() => _TaskDetailsSheetState();
|
||||||
@@ -18,26 +23,65 @@ class TaskDetailsSheet extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
|
class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
|
||||||
final TextEditingController _commentController = TextEditingController();
|
final TextEditingController _commentController = TextEditingController();
|
||||||
final List<String> _imagesBase64 = [];
|
final ImagePicker _picker = ImagePicker();
|
||||||
|
final List<Map<String, String>> _attachments = [];
|
||||||
bool _isSubmitting = false;
|
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 {
|
Future<void> _pickImage() async {
|
||||||
if (_imagesBase64.length >= 3) {
|
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
|
||||||
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);
|
|
||||||
if (image != null) {
|
if (image != null) {
|
||||||
final bytes = await image.readAsBytes();
|
final bytes = await image.readAsBytes();
|
||||||
setState(() {
|
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 {
|
Future<void> _submitComment() async {
|
||||||
if (_commentController.text.trim().isEmpty && _imagesBase64.isEmpty) return;
|
if (_commentController.text.trim().isEmpty && _attachments.isEmpty) return;
|
||||||
|
|
||||||
setState(() => _isSubmitting = true);
|
setState(() => _isSubmitting = true);
|
||||||
try {
|
try {
|
||||||
@@ -45,11 +89,11 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
|
|||||||
await repo.addComment(
|
await repo.addComment(
|
||||||
taskId: widget.task.id,
|
taskId: widget.task.id,
|
||||||
content: _commentController.text.trim(),
|
content: _commentController.text.trim(),
|
||||||
imagesBase64: _imagesBase64,
|
attachments: _attachments,
|
||||||
);
|
);
|
||||||
_commentController.clear();
|
_commentController.clear();
|
||||||
setState(() {
|
setState(() {
|
||||||
_imagesBase64.clear();
|
_attachments.clear();
|
||||||
});
|
});
|
||||||
ref.invalidate(projectTaskCommentsProvider(widget.task.id));
|
ref.invalidate(projectTaskCommentsProvider(widget.task.id));
|
||||||
} catch (e) {
|
} 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final commentsAsync = ref.watch(projectTaskCommentsProvider(widget.task.id));
|
final commentsAsync = ref.watch(projectTaskCommentsProvider(widget.task.id));
|
||||||
@@ -99,30 +177,126 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
|
|||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: c.images.asMap().entries.map((entry) {
|
children: c.images.asMap().entries.map((entry) {
|
||||||
final imgIndex = entry.key;
|
final imgIndex = entry.key;
|
||||||
final img = entry.value;
|
final imgData = entry.value;
|
||||||
return GestureDetector(
|
|
||||||
onTap: () {
|
bool isOldBase64 = !imgData.startsWith('{');
|
||||||
final allImages = c.images.map((i) => MemoryImage(base64Decode(i)) as ImageProvider).toList();
|
Map<String, dynamic>? parsedData;
|
||||||
Navigator.push(
|
if (!isOldBase64) {
|
||||||
context,
|
try {
|
||||||
MaterialPageRoute(
|
parsedData = jsonDecode(imgData);
|
||||||
builder: (context) => AttachmentGalleryScreen(
|
} catch (_) {
|
||||||
images: allImages,
|
isOldBase64 = true;
|
||||||
initialIndex: imgIndex,
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
} else {
|
||||||
borderRadius: BorderRadius.circular(8),
|
// Non-image file
|
||||||
child: Image.memory(
|
final fileName = parsedData?['fileName'] ?? 'Document';
|
||||||
base64Decode(img),
|
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,
|
width: 80,
|
||||||
height: 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(),
|
}).toList(),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@@ -181,22 +355,39 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (_imagesBase64.isNotEmpty)
|
if (_attachments.isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8.0),
|
padding: const EdgeInsets.only(bottom: 8.0),
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
spacing: 8,
|
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(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
Container(
|
||||||
borderRadius: BorderRadius.circular(8),
|
width: 60,
|
||||||
child: Image.memory(
|
height: 60,
|
||||||
base64Decode(entry.value),
|
decoration: BoxDecoration(
|
||||||
width: 60,
|
color: Colors.grey.shade200,
|
||||||
height: 60,
|
borderRadius: BorderRadius.circular(8),
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
),
|
||||||
|
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(
|
Positioned(
|
||||||
right: 0,
|
right: 0,
|
||||||
@@ -204,7 +395,7 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
_imagesBase64.removeAt(entry.key);
|
_attachments.removeAt(entry.key);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -224,6 +415,10 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
|
|||||||
icon: const Icon(LucideIcons.image),
|
icon: const Icon(LucideIcons.image),
|
||||||
onPressed: _pickImage,
|
onPressed: _pickImage,
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(LucideIcons.paperclip),
|
||||||
|
onPressed: _pickFile,
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _commentController,
|
controller: _commentController,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import file_picker_darwin
|
||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
import flutter_image_compress_macos
|
import flutter_image_compress_macos
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
@@ -16,6 +17,7 @@ import share_plus
|
|||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
|
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.4.1"
|
version: "8.4.1"
|
||||||
|
android_file_picker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: android_file_picker
|
||||||
|
sha256: "665a5a57dfca27f91a715d300e4852a784f9f98e503dcff281bec9afb55767be"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -241,6 +249,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
file_picker:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: file_picker
|
||||||
|
sha256: afbaa8015d9efabd224f41084ed9fdeddfa65389ebd7cd3a9eb1476aca66b46d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "12.0.0"
|
||||||
|
file_picker_darwin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_picker_darwin
|
||||||
|
sha256: "5d87d156c1d63920447a662b7117d3498c67e3444a44d2cb01ed955d6efa62ef"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
|
file_picker_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_picker_linux
|
||||||
|
sha256: "93d3f62f97c657053e7b184fe0f5e22347d85067c053640a30b1ac8ad7844e3b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
|
file_picker_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_picker_platform_interface
|
||||||
|
sha256: "9e7a7e01e179929241f0afeb2c8c69ac95e29e17c0abea36ed193881c6bef90c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.1"
|
||||||
|
file_picker_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_picker_web
|
||||||
|
sha256: f1af38b3c91fafe0ca97f659b5c6818a057473ef09bb8b722f9f3f5364aa7eed
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.1"
|
||||||
file_selector_linux:
|
file_selector_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1325,6 +1373,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.4.0"
|
version: "6.4.0"
|
||||||
|
windows_file_picker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: windows_file_picker
|
||||||
|
sha256: "72cf23466e146f2c0f19e1d78be97ff6409dc15b0c090f8eb284f94f4a33de26"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ dependencies:
|
|||||||
screenshot: ^3.0.0
|
screenshot: ^3.0.0
|
||||||
pdf: ^3.12.0
|
pdf: ^3.12.0
|
||||||
printing: ^5.14.3
|
printing: ^5.14.3
|
||||||
|
file_picker: ^12.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
93
scratch/ProjectServiceUpdate.java
Normal file
93
scratch/ProjectServiceUpdate.java
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package com.kifi.api.service;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.project.Project;
|
||||||
|
import com.kifi.api.entity.project.ProjectTask;
|
||||||
|
import com.kifi.api.entity.project.ProjectTaskComment;
|
||||||
|
import com.kifi.api.repository.ProjectRepository;
|
||||||
|
import com.kifi.api.repository.ProjectTaskRepository;
|
||||||
|
import com.kifi.api.repository.ProjectTaskCommentRepository;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class ProjectService {
|
||||||
|
|
||||||
|
private final ProjectRepository projectRepository;
|
||||||
|
private final ProjectTaskRepository projectTaskRepository;
|
||||||
|
private final ProjectTaskCommentRepository projectTaskCommentRepository;
|
||||||
|
private final MinioServiceClient minioServiceClient;
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
// ... existing code ...
|
||||||
|
|
||||||
|
public Mono<ProjectTaskComment> addComment(Long taskId, Long userId, String content, String imagesJson) {
|
||||||
|
ProjectTaskComment comment = new ProjectTaskComment();
|
||||||
|
comment.setTaskId(taskId);
|
||||||
|
comment.setUserId(userId);
|
||||||
|
comment.setContent(content);
|
||||||
|
comment.setCreatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
if (imagesJson == null || imagesJson.trim().isEmpty() || imagesJson.equals("[]")) {
|
||||||
|
return projectTaskCommentRepository.save(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<Map<String, String>> attachments = objectMapper.readValue(imagesJson, new TypeReference<List<Map<String, String>>>() {});
|
||||||
|
|
||||||
|
// Check if it's the old format (just base64 strings)
|
||||||
|
if (!attachments.isEmpty() && attachments.get(0).containsKey("base64Content") == false) {
|
||||||
|
// It's just strings (old format), we shouldn't really hit this with the new app version
|
||||||
|
comment.setImages(imagesJson);
|
||||||
|
return projectTaskCommentRepository.save(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Flux.fromIterable(attachments)
|
||||||
|
.flatMap(att -> {
|
||||||
|
String fileName = att.get("fileName");
|
||||||
|
String contentType = att.get("contentType");
|
||||||
|
String base64Content = att.get("base64Content");
|
||||||
|
String uniqueFileName = UUID.randomUUID().toString() + "_" + fileName;
|
||||||
|
String directoryPath = "tasks/" + taskId;
|
||||||
|
|
||||||
|
return minioServiceClient.uploadFile(directoryPath, contentType, uniqueFileName, base64Content)
|
||||||
|
.map(minioResponse -> {
|
||||||
|
Map<String, String> result = new HashMap<>();
|
||||||
|
result.put("fileName", fileName);
|
||||||
|
result.put("contentType", contentType);
|
||||||
|
if (minioResponse.isSuccess()) {
|
||||||
|
result.put("filePath", minioResponse.getFilePath());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.collectList()
|
||||||
|
.flatMap(uploadedAttachments -> {
|
||||||
|
try {
|
||||||
|
String newImagesJson = objectMapper.writeValueAsString(uploadedAttachments);
|
||||||
|
comment.setImages(newImagesJson);
|
||||||
|
return projectTaskCommentRepository.save(comment);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Mono.error(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Fallback for old simple string array
|
||||||
|
comment.setImages(imagesJson);
|
||||||
|
return projectTaskCommentRepository.save(comment);
|
||||||
|
}
|
||||||
|
}
|
||||||
439
scratch/UpdateTaskDetailsSheet.dart
Normal file
439
scratch/UpdateTaskDetailsSheet.dart
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
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({Key? key, required this.task}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<TaskDetailsSheet> createState() => _TaskDetailsSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
|
||||||
|
final TextEditingController _commentController = TextEditingController();
|
||||||
|
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 {
|
||||||
|
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
|
||||||
|
if (image != null) {
|
||||||
|
final bytes = await image.readAsBytes();
|
||||||
|
setState(() {
|
||||||
|
_attachments.add({
|
||||||
|
'fileName': image.name,
|
||||||
|
'contentType': 'image/jpeg',
|
||||||
|
'base64Content': base64Encode(bytes),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickFile() async {
|
||||||
|
FilePickerResult? result = await FilePicker.platform.pickFiles();
|
||||||
|
if (result != null && result.files.single.path != null) {
|
||||||
|
final file = File(result.files.single.path!);
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
final extension = result.files.single.extension?.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': result.files.single.name,
|
||||||
|
'contentType': contentType,
|
||||||
|
'base64Content': base64Encode(bytes),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submitComment() async {
|
||||||
|
if (_commentController.text.trim().isEmpty && _attachments.isEmpty) return;
|
||||||
|
|
||||||
|
setState(() => _isSubmitting = true);
|
||||||
|
try {
|
||||||
|
final repo = ref.read(projectRepositoryProvider);
|
||||||
|
await repo.addComment(
|
||||||
|
taskId: widget.task.id,
|
||||||
|
content: _commentController.text.trim(),
|
||||||
|
attachments: _attachments,
|
||||||
|
);
|
||||||
|
_commentController.clear();
|
||||||
|
setState(() {
|
||||||
|
_attachments.clear();
|
||||||
|
});
|
||||||
|
ref.invalidate(projectTaskCommentsProvider(widget.task.id));
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error adding comment: $e')));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isSubmitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.9,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildHeader(context),
|
||||||
|
Expanded(
|
||||||
|
child: commentsAsync.when(
|
||||||
|
data: (comments) {
|
||||||
|
if (comments.isEmpty) return const Center(child: Text('No comments yet.'));
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: comments.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final c = comments[index];
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
color: Colors.grey[100],
|
||||||
|
elevation: 0,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(c.content, style: const TextStyle(fontSize: 14)),
|
||||||
|
if (c.images.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: c.images.asMap().entries.map((entry) {
|
||||||
|
final imgIndex = entry.key;
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} 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,
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildCommentInput(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: Colors.grey.shade300))),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(widget.task.title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
if (widget.task.description != null && widget.task.description!.isNotEmpty)
|
||||||
|
Text(widget.task.description!, style: const TextStyle(color: Colors.grey)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(context)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCommentInput() {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
top: 16,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (_attachments.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8.0),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: _attachments.asMap().entries.map((entry) {
|
||||||
|
final att = entry.value;
|
||||||
|
final isImg = att['contentType']?.startsWith('image/') ?? false;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
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,
|
||||||
|
top: 0,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_attachments.removeAt(entry.key);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black54,
|
||||||
|
child: const Icon(LucideIcons.x, size: 16, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(LucideIcons.image),
|
||||||
|
onPressed: _pickImage,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(LucideIcons.paperclip),
|
||||||
|
onPressed: _pickFile,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _commentController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Add a comment...',
|
||||||
|
border: InputBorder.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_isSubmitting
|
||||||
|
? const Padding(padding: EdgeInsets.all(12.0), child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)))
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(LucideIcons.send, color: Colors.blue),
|
||||||
|
onPressed: _submitComment,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user