Project management bug fixes

This commit is contained in:
2026-08-24 23:23:01 +05:30
parent 2a106a8716
commit 82c1a891c0
12 changed files with 932 additions and 53 deletions

View File

@@ -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

View File

@@ -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),
)),

View File

@@ -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);
}

View File

@@ -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));
}
}
}
}

View File

@@ -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,

View File

@@ -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"))

View File

@@ -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:

View File

@@ -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: