569 lines
19 KiB
Dart
569 lines
19 KiB
Dart
import 'dart:io';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import 'package:taxglide/consts/download_helper.dart';
|
|
import 'package:taxglide/controller/api_contoller.dart';
|
|
import 'package:taxglide/controller/api_repository.dart';
|
|
import 'package:taxglide/model/chat_model.dart';
|
|
import 'package:taxglide/view/Mahi_chat/live_chat_screen.dart';
|
|
|
|
// ⭐⭐⭐ CHAT INPUT BOX WITH FILE TAG SUPPORT ⭐⭐⭐
|
|
class ChatInputBox extends ConsumerStatefulWidget {
|
|
final int chatId;
|
|
final VoidCallback? onMessageSent;
|
|
|
|
const ChatInputBox(this.chatId, {Key? key, this.onMessageSent})
|
|
: super(key: key);
|
|
|
|
@override
|
|
ConsumerState<ChatInputBox> createState() => _ChatInputBoxState();
|
|
}
|
|
|
|
class _ChatInputBoxState extends ConsumerState<ChatInputBox> {
|
|
final TextEditingController messageCtrl = TextEditingController();
|
|
final ImagePicker picker = ImagePicker();
|
|
|
|
List<File> selectedFiles = [];
|
|
bool _isSending = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_requestPermissions();
|
|
}
|
|
|
|
// ⭐ Request storage permissions
|
|
Future<void> _requestPermissions() async {
|
|
await DownloadHelper.requestStoragePermission();
|
|
}
|
|
|
|
void showPickerMenu() {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
backgroundColor: Colors.white,
|
|
isScrollControlled: true,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(15)),
|
|
),
|
|
builder: (context) {
|
|
return SafeArea(
|
|
bottom: true,
|
|
child: SizedBox(
|
|
height: 220,
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: 10),
|
|
ListTile(
|
|
leading: const Icon(Icons.photo_camera, size: 28),
|
|
title: const Text("Camera", style: TextStyle(fontSize: 17)),
|
|
onTap: pickFromCamera,
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.photo_library, size: 28),
|
|
title: const Text("Gallery", style: TextStyle(fontSize: 17)),
|
|
onTap: pickFromGallery,
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.attach_file, size: 28),
|
|
title: const Text("Files", style: TextStyle(fontSize: 17)),
|
|
onTap: pickDocuments,
|
|
),
|
|
const SizedBox(height: 10),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future pickFromCamera() async {
|
|
Navigator.pop(context);
|
|
|
|
try {
|
|
var status = await Permission.camera.status;
|
|
|
|
// Handle cases where we cannot show the system dialog again
|
|
// Skip strict blocks on iOS Simulator by just trying to launch picker
|
|
if (!Platform.isIOS || status.isGranted || status.isLimited) {
|
|
if (status.isPermanentlyDenied || (Platform.isIOS && status.isDenied)) {
|
|
status = await Permission.camera.request();
|
|
if (!status.isGranted && !status.isLimited) {
|
|
_showPermissionDialog("Camera");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
final XFile? image = await picker.pickImage(
|
|
source: ImageSource.camera,
|
|
maxWidth: 1920,
|
|
maxHeight: 1080,
|
|
imageQuality: 85,
|
|
);
|
|
|
|
if (image != null) {
|
|
setState(() => selectedFiles.add(File(image.path)));
|
|
}
|
|
} catch (e) {
|
|
debugPrint("❌ Camera error: $e");
|
|
// If it's a simulator, it will throw an error since there is no camera
|
|
if (Platform.isIOS && e.toString().contains("camera not available")) {
|
|
_showErrorSnackbar("Camera not available on iOS Simulator");
|
|
} else {
|
|
_showErrorSnackbar("Failed to capture image");
|
|
}
|
|
}
|
|
}
|
|
|
|
Future pickFromGallery() async {
|
|
Navigator.pop(context);
|
|
|
|
try {
|
|
var status = await Permission.photos.status;
|
|
|
|
if (status.isPermanentlyDenied || (Platform.isIOS && status.isDenied)) {
|
|
status = await Permission.photos.request();
|
|
if (!status.isGranted && !status.isLimited && !Platform.isIOS) {
|
|
_showPermissionDialog("Photos");
|
|
return;
|
|
}
|
|
}
|
|
|
|
final List<XFile>? images = await picker.pickMultiImage(
|
|
maxWidth: 1920,
|
|
maxHeight: 1080,
|
|
imageQuality: 85,
|
|
);
|
|
|
|
if (images != null && images.isNotEmpty) {
|
|
setState(() {
|
|
selectedFiles.addAll(images.map((e) => File(e.path)));
|
|
});
|
|
}
|
|
} catch (e) {
|
|
print("❌ Gallery error: $e");
|
|
_showErrorSnackbar("Failed to pick images");
|
|
}
|
|
}
|
|
|
|
Future pickDocuments() async {
|
|
Navigator.pop(context);
|
|
|
|
try {
|
|
FilePickerResult? result = await FilePicker.platform.pickFiles(
|
|
allowMultiple: true,
|
|
type: FileType.any,
|
|
);
|
|
|
|
if (result != null && result.files.isNotEmpty) {
|
|
setState(() {
|
|
selectedFiles.addAll(
|
|
result.paths.where((e) => e != null).map((e) => File(e!)),
|
|
);
|
|
});
|
|
}
|
|
} catch (e) {
|
|
print("❌ File picker error: $e");
|
|
_showErrorSnackbar("Failed to pick files");
|
|
}
|
|
}
|
|
|
|
void _showPermissionDialog(String permissionName) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text("$permissionName Permission Required"),
|
|
content: Text(
|
|
"Allow Taxglide to access your $permissionName to capture and upload files easily. You can enable this in the app settings.",
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text("Cancel"),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
openAppSettings();
|
|
},
|
|
child: const Text("Go to Settings"),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showErrorSnackbar(String message) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
backgroundColor: Colors.red,
|
|
content: Text(message),
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
|
|
void removeFile(int index) {
|
|
setState(() {
|
|
selectedFiles.removeAt(index);
|
|
});
|
|
}
|
|
|
|
String getFileName(String path) {
|
|
return path.split('/').last;
|
|
}
|
|
|
|
IconData getFileIcon(String path) {
|
|
final ext = path.split('.').last.toLowerCase();
|
|
|
|
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext)) {
|
|
return Icons.image;
|
|
} else if (ext == 'pdf') {
|
|
return Icons.picture_as_pdf;
|
|
} else if (['doc', 'docx'].contains(ext)) {
|
|
return Icons.description;
|
|
} else if (['xls', 'xlsx'].contains(ext)) {
|
|
return Icons.table_chart;
|
|
} else {
|
|
return Icons.insert_drive_file;
|
|
}
|
|
}
|
|
|
|
Future<void> sendMessage() async {
|
|
if (_isSending) return; // Prevent double-tap
|
|
|
|
String text = messageCtrl.text.trim();
|
|
|
|
if (text.isEmpty && selectedFiles.isEmpty) return;
|
|
|
|
// ⭐ Get the tagged message
|
|
final taggedMessage = ref.read(taggedMessageProvider);
|
|
final tagId = taggedMessage?.id ?? 0;
|
|
|
|
setState(() => _isSending = true);
|
|
|
|
// ⭐ OPTIMISTIC UI: Create a temporary message and add it immediately
|
|
final tempId = DateTime.now().millisecondsSinceEpoch;
|
|
final tempMsg = MessageModel(
|
|
id: tempId,
|
|
chatBy: "user", // User
|
|
message: text,
|
|
type: selectedFiles.isNotEmpty ? "file" : "text",
|
|
isDelivered: 0,
|
|
isRead: 0,
|
|
createdAt: DateTime.now().toIso8601String(),
|
|
userId: 0,
|
|
tagId: tagId > 0 ? tagId : null,
|
|
parentTag: taggedMessage != null ? ParentTagModel(
|
|
id: taggedMessage.id,
|
|
message: taggedMessage.message,
|
|
type: taggedMessage.type,
|
|
fileName: taggedMessage.fileName,
|
|
) : null,
|
|
uploadedDocuments: selectedFiles.map((file) => DocumentModel(
|
|
filePath: file.path,
|
|
fileName: file.path.split('/').last,
|
|
fileType: "image",
|
|
)).toList(),
|
|
documents: [],
|
|
);
|
|
|
|
// Add to UI immediately
|
|
final notifier = ref.read(chatMessagesProvider(widget.chatId).notifier);
|
|
notifier.addNewMessage(tempMsg);
|
|
|
|
// Clear input immediately for snappy feel
|
|
final savedSelectedFiles = List<File>.from(selectedFiles);
|
|
messageCtrl.clear();
|
|
selectedFiles.clear();
|
|
ref.read(taggedMessageProvider.notifier).state = null;
|
|
|
|
// Notify parent to scroll to bottom instantly
|
|
if (widget.onMessageSent != null) {
|
|
widget.onMessageSent!();
|
|
}
|
|
|
|
try {
|
|
List<File> filesToUpload = [];
|
|
|
|
// ⭐⭐⭐ Save files to Send folder
|
|
if (savedSelectedFiles.isNotEmpty) {
|
|
for (File file in savedSelectedFiles) {
|
|
try {
|
|
// Try to save to Send folder, fallback to original if fails
|
|
final savedPath = await DownloadHelper.saveToSendFolder(file);
|
|
filesToUpload.add(File(savedPath));
|
|
} catch (e) {
|
|
filesToUpload.add(file);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Send message with files
|
|
await ApiRepository().sendChatMessage(
|
|
chatId: widget.chatId,
|
|
messages: text,
|
|
tagId: tagId,
|
|
files: filesToUpload,
|
|
);
|
|
|
|
// Refresh chat messages (this will replace the temp message with the real one)
|
|
await notifier.refresh();
|
|
ref.invalidate(chatDocumentProvider);
|
|
|
|
if (mounted) {
|
|
setState(() => _isSending = false);
|
|
}
|
|
} catch (e) {
|
|
print("❌ Error sending message: $e");
|
|
|
|
if (mounted) {
|
|
setState(() => _isSending = false);
|
|
}
|
|
|
|
// If it failed, delete the temporary message
|
|
notifier.deleteMessage(tempId);
|
|
|
|
// Show error message
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
backgroundColor: Colors.red,
|
|
content: Row(
|
|
children: [
|
|
const Icon(Icons.error_outline, color: Colors.white),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
"Failed to send: ${e.toString()}",
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
duration: const Duration(seconds: 4),
|
|
action: SnackBarAction(
|
|
label: "Retry",
|
|
textColor: Colors.white,
|
|
onPressed: sendMessage,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final taggedMessage = ref.watch(taggedMessageProvider);
|
|
|
|
return SafeArea(
|
|
bottom: true,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
color: Colors.white,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// ⭐⭐⭐ SELECTED FILES PREVIEW (like WhatsApp)
|
|
if (selectedFiles.isNotEmpty)
|
|
Container(
|
|
height: 100,
|
|
margin: const EdgeInsets.only(bottom: 10),
|
|
child: ListView.builder(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: selectedFiles.length,
|
|
itemBuilder: (context, index) {
|
|
final file = selectedFiles[index];
|
|
final isImage = DownloadHelper.isImageFile(file.path);
|
|
|
|
return Container(
|
|
width: 80,
|
|
margin: const EdgeInsets.only(right: 8),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: Colors.grey[300]!),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Stack(
|
|
children: [
|
|
// File preview
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: isImage
|
|
? Image.file(
|
|
file,
|
|
width: 80,
|
|
height: 100,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return const Center(
|
|
child: Icon(Icons.broken_image),
|
|
);
|
|
},
|
|
)
|
|
: Center(
|
|
child: Column(
|
|
mainAxisAlignment:
|
|
MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
getFileIcon(file.path),
|
|
size: 30,
|
|
color: Colors.grey[600],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 4,
|
|
),
|
|
child: Text(
|
|
getFileName(file.path),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(fontSize: 9),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// Remove button
|
|
Positioned(
|
|
top: 2,
|
|
right: 2,
|
|
child: GestureDetector(
|
|
onTap: () => removeFile(index),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(2),
|
|
decoration: const BoxDecoration(
|
|
color: Colors.black54,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Icons.close,
|
|
size: 16,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
|
|
// Input Row
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Container(
|
|
constraints: const BoxConstraints(
|
|
minHeight: 62,
|
|
maxHeight: 150,
|
|
),
|
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(7),
|
|
border: Border.all(color: const Color(0xFFCACACA)),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: Colors.black26,
|
|
offset: Offset(0, 4),
|
|
blurRadius: 7.8,
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: messageCtrl,
|
|
maxLines: null,
|
|
minLines: 1,
|
|
keyboardType: TextInputType.multiline,
|
|
textInputAction: TextInputAction.newline,
|
|
decoration: InputDecoration(
|
|
hintText: taggedMessage != null
|
|
? "Reply to ${taggedMessage.user?.name ?? 'message'}"
|
|
: "Send your message",
|
|
border: InputBorder.none,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
vertical: 12,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
GestureDetector(
|
|
onTap: _isSending ? null : showPickerMenu,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Icon(
|
|
Icons.attach_file,
|
|
size: 26,
|
|
color: _isSending ? Colors.grey : Colors.black,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
const SizedBox(width: 10),
|
|
|
|
GestureDetector(
|
|
onTap: _isSending ? null : sendMessage,
|
|
child: Container(
|
|
height: 46,
|
|
width: 46,
|
|
decoration: BoxDecoration(
|
|
color: _isSending ? Colors.grey : const Color(0xFF08710C),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Center(
|
|
child: _isSending
|
|
? const SizedBox(
|
|
width: 22,
|
|
height: 22,
|
|
child: CircularProgressIndicator(
|
|
color: Colors.white,
|
|
strokeWidth: 2,
|
|
),
|
|
)
|
|
: const Icon(
|
|
Icons.send,
|
|
color: Colors.white,
|
|
size: 22,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
messageCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
}
|