diff --git a/kifi-api/src/main/java/com/kifi/api/controller/ProjectController.java b/kifi-api/src/main/java/com/kifi/api/controller/ProjectController.java index a499269..2628019 100644 --- a/kifi-api/src/main/java/com/kifi/api/controller/ProjectController.java +++ b/kifi-api/src/main/java/com/kifi/api/controller/ProjectController.java @@ -109,6 +109,16 @@ public class ProjectController { return projectService.addComment(taskId, userId, request.getContent(), request.getImagesJson()); } + @GetMapping("/tasks/attachments/download") + public Mono> 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 static class CreateProjectRequest { diff --git a/kifi-api/src/main/java/com/kifi/api/service/ProjectService.java b/kifi-api/src/main/java/com/kifi/api/service/ProjectService.java index 84ec746..e894198 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/ProjectService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/ProjectService.java @@ -13,6 +13,10 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; 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 @RequiredArgsConstructor @@ -22,6 +26,8 @@ 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(); public Flux getProjectsByUser(Long userId) { return projectRepository.findByUserId(userId); @@ -108,10 +114,65 @@ public class ProjectService { comment.setTaskId(taskId); comment.setUserId(userId); comment.setContent(content); - if (imagesJson != null && !imagesJson.isEmpty()) { - comment.setImages(imagesJson); - } comment.setCreatedAt(LocalDateTime.now()); - return projectTaskCommentRepository.save(comment); + + if (imagesJson == null || imagesJson.trim().isEmpty() || imagesJson.equals("[]")) { + return projectTaskCommentRepository.save(comment); + } + + try { + List> attachments = objectMapper.readValue(imagesJson, new TypeReference>>() {}); + + // 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 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 downloadAttachment(String contentType, String filePath) { + return minioServiceClient.downloadFile(contentType, filePath); } } \ No newline at end of file diff --git a/kifi-app/ios/Podfile.lock b/kifi-app/ios/Podfile.lock index 2fce58e..96bc1be 100644 --- a/kifi-app/ios/Podfile.lock +++ b/kifi-app/ios/Podfile.lock @@ -1,4 +1,7 @@ PODS: + - file_picker_darwin (1.0.0): + - Flutter + - FlutterMacOS - Flutter (1.0.0) - flutter_image_compress_common (1.0.0): - Flutter @@ -101,6 +104,7 @@ PODS: - FlutterMacOS DEPENDENCIES: + - file_picker_darwin (from `.symlinks/plugins/file_picker_darwin/darwin`) - Flutter (from `Flutter`) - flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) @@ -133,6 +137,8 @@ SPEC REPOS: - SDWebImageWebPCoder EXTERNAL SOURCES: + file_picker_darwin: + :path: ".symlinks/plugins/file_picker_darwin/darwin" Flutter: :path: Flutter flutter_image_compress_common: @@ -159,6 +165,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" SPEC CHECKSUMS: + file_picker_darwin: 65c8ed1d70cc2ea0b81ae1840db93f8d9c051d19 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_image_compress_common: 11d5dcfb36f4ded92320bfa896261813a073240e flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214 diff --git a/kifi-app/lib/core/network/dio_client.dart b/kifi-app/lib/core/network/dio_client.dart index 502fe26..0733ab7 100644 --- a/kifi-app/lib/core/network/dio_client.dart +++ b/kifi-app/lib/core/network/dio_client.dart @@ -15,8 +15,8 @@ class DioClient { DioClient._internal() : dio = Dio(BaseOptions( - //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: 'https://app.technobeesolutions.in/api/kifi-v2', + //baseUrl: 'http://192.168.0.104:8080/api/kifi-v2', // Local Mac IP for physical device testing connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), )), diff --git a/kifi-app/lib/features/projects/data/project_repository.dart b/kifi-app/lib/features/projects/data/project_repository.dart index ee83d42..b42abb5 100644 --- a/kifi-app/lib/features/projects/data/project_repository.dart +++ b/kifi-app/lib/features/projects/data/project_repository.dart @@ -119,12 +119,12 @@ Future> getComments(int taskId) async { Future addComment({ required int taskId, required String content, - List? imagesBase64, + List>? 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); } diff --git a/kifi-app/lib/features/projects/domain/project_task_comment.dart b/kifi-app/lib/features/projects/domain/project_task_comment.dart index f7e57eb..ebaf049 100644 --- a/kifi-app/lib/features/projects/domain/project_task_comment.dart +++ b/kifi-app/lib/features/projects/domain/project_task_comment.dart @@ -22,10 +22,25 @@ class ProjectTaskComment { if (json['images'] != null) { if (json['images'] is String) { try { - parsedImages = List.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.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)); + } + } } } diff --git a/kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart b/kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart index c69f3c6..87cceab 100644 --- a/kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart +++ b/kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart @@ -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 createState() => _TaskDetailsSheetState(); @@ -18,26 +23,65 @@ class TaskDetailsSheet extends ConsumerStatefulWidget { class _TaskDetailsSheetState extends ConsumerState { final TextEditingController _commentController = TextEditingController(); - final List _imagesBase64 = []; + final ImagePicker _picker = ImagePicker(); + final List> _attachments = []; bool _isSubmitting = false; + String? _jwtToken; + final Set _downloadingIds = {}; + + @override + void initState() { + super.initState(); + _loadToken(); + } + + Future _loadToken() async { + const storage = FlutterSecureStorage(); + _jwtToken = await storage.read(key: 'jwt_token'); + if (mounted) setState(() {}); + } Future _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 _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 _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 { 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 { } } + Future _downloadAttachment(Map 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 { 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? 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 { 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 { child: GestureDetector( onTap: () { setState(() { - _imagesBase64.removeAt(entry.key); + _attachments.removeAt(entry.key); }); }, child: Container( @@ -224,6 +415,10 @@ class _TaskDetailsSheetState extends ConsumerState { icon: const Icon(LucideIcons.image), onPressed: _pickImage, ), + IconButton( + icon: const Icon(LucideIcons.paperclip), + onPressed: _pickFile, + ), Expanded( child: TextField( controller: _commentController, diff --git a/kifi-app/macos/Flutter/GeneratedPluginRegistrant.swift b/kifi-app/macos/Flutter/GeneratedPluginRegistrant.swift index 6a152d1..be3769b 100644 --- a/kifi-app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/kifi-app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import file_picker_darwin import file_selector_macos import flutter_image_compress_macos import flutter_local_notifications @@ -16,6 +17,7 @@ import share_plus import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) diff --git a/kifi-app/pubspec.lock b/kifi-app/pubspec.lock index acf8c66..e5b02e1 100644 --- a/kifi-app/pubspec.lock +++ b/kifi-app/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -241,6 +249,46 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -1325,6 +1373,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/kifi-app/pubspec.yaml b/kifi-app/pubspec.yaml index f2032fb..5a2c067 100644 --- a/kifi-app/pubspec.yaml +++ b/kifi-app/pubspec.yaml @@ -57,6 +57,7 @@ dependencies: screenshot: ^3.0.0 pdf: ^3.12.0 printing: ^5.14.3 + file_picker: ^12.0.0 dev_dependencies: flutter_test: diff --git a/scratch/ProjectServiceUpdate.java b/scratch/ProjectServiceUpdate.java new file mode 100644 index 0000000..728df9e --- /dev/null +++ b/scratch/ProjectServiceUpdate.java @@ -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 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> attachments = objectMapper.readValue(imagesJson, new TypeReference>>() {}); + + // 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 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); + } + } diff --git a/scratch/UpdateTaskDetailsSheet.dart b/scratch/UpdateTaskDetailsSheet.dart new file mode 100644 index 0000000..9289fd9 --- /dev/null +++ b/scratch/UpdateTaskDetailsSheet.dart @@ -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 createState() => _TaskDetailsSheetState(); +} + +class _TaskDetailsSheetState extends ConsumerState { + final TextEditingController _commentController = TextEditingController(); + final ImagePicker _picker = ImagePicker(); + final List> _attachments = []; + bool _isSubmitting = false; + String? _jwtToken; + final Set _downloadingIds = {}; + + @override + void initState() { + super.initState(); + _loadToken(); + } + + Future _loadToken() async { + const storage = FlutterSecureStorage(); + _jwtToken = await storage.read(key: 'jwt_token'); + if (mounted) setState(() {}); + } + + Future _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 _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 _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 _downloadAttachment(Map 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? 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, + ), + ], + ), + ], + ), + ); + } +}