578 lines
24 KiB
Dart
578 lines
24 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:lucide_icons_flutter/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 {
|
|
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 && _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-v2/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-v2/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-v2/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,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|