Files
Kifi/kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.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/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,
),
],
),
],
),
);
}
}