2026-04-11
This commit is contained in:
commit
27e27fb70b
270 files changed
+30296
No files matched your search
@@ -0,0 +1,373 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:taxglide/consts/download_helper.dart';
|
||||
import 'package:taxglide/controller/api_contoller.dart';
|
||||
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
|
||||
class ChatProfileScreen extends ConsumerWidget {
|
||||
final int chatid;
|
||||
|
||||
const ChatProfileScreen({super.key, required this.chatid});
|
||||
|
||||
// Helper function to check if document is valid
|
||||
bool _isValidDocument(dynamic doc) {
|
||||
// Check if fileName exists and is not empty
|
||||
final hasFileName =
|
||||
doc.fileName != null && doc.fileName.toString().trim().isNotEmpty;
|
||||
|
||||
// Check if filePath exists and is not empty
|
||||
final hasFilePath =
|
||||
doc.filePath != null && doc.filePath.toString().trim().isNotEmpty;
|
||||
|
||||
// Return true only if both fileName and filePath exist
|
||||
return hasFileName && hasFilePath;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final result = ref.watch(chatDocumentProvider(chatid));
|
||||
|
||||
return Scaffold(
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(240),
|
||||
child: AppBar(
|
||||
automaticallyImplyLeading: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const Text(
|
||||
"Profile",
|
||||
style: TextStyle(
|
||||
fontFamily: "Gilroy-SemiBold",
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 20,
|
||||
height: 1.4,
|
||||
letterSpacing: 0.6,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
|
||||
flexibleSpace: result.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, st) => Center(child: Text(e.toString())),
|
||||
data: (data) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 282,
|
||||
padding: const EdgeInsets.only(top: 100, bottom: 20),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: const Color(0xFFDEDEDE),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 45,
|
||||
backgroundImage: NetworkImage(data.profile),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
data.name,
|
||||
style: Theme.of(context).textTheme.titleLarge!.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
data.role,
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[700]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
body: result.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, st) => Center(child: Text(e.toString())),
|
||||
data: (data) {
|
||||
// Filter out invalid documents
|
||||
final senderList = data.userUploadedDocuments
|
||||
.where((doc) => _isValidDocument(doc))
|
||||
.toList();
|
||||
|
||||
final receiverList = data.adminUploadedDocuments
|
||||
.where((doc) => _isValidDocument(doc))
|
||||
.toList();
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ---------------- Shared by Taxglide ----------------
|
||||
const Text(
|
||||
"Shared By Taxglide",
|
||||
style: TextStyle(
|
||||
fontFamily: "Gilroy-SemiBold",
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 20,
|
||||
height: 1.4,
|
||||
letterSpacing: 0.6,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
|
||||
receiverList.isEmpty
|
||||
? const Text("No documents from Taxglide")
|
||||
: SizedBox(
|
||||
height: 110,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: receiverList.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(width: 12),
|
||||
itemBuilder: (_, index) {
|
||||
final doc = receiverList[index];
|
||||
final isImage = DownloadHelper.isImageFile(
|
||||
doc.filePath,
|
||||
);
|
||||
final isPdf =
|
||||
DownloadHelper.getFileExtension(doc.filePath) ==
|
||||
'pdf';
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (isImage) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ImagePreviewScreen(
|
||||
imageUrl: doc.filePath,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (isPdf) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PdfPreviewScreen(
|
||||
pdfUrl: doc.filePath,
|
||||
fileName: doc.fileName,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: 100,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: isImage
|
||||
? ClipRRect(
|
||||
borderRadius:
|
||||
BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
doc.filePath,
|
||||
width: 100,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: isPdf
|
||||
? const Icon(
|
||||
Icons.picture_as_pdf,
|
||||
size: 40,
|
||||
color: Colors.red,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.insert_drive_file,
|
||||
size: 40,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
|
||||
// ---------------- Shared by Client ----------------
|
||||
const Text(
|
||||
"Shared By Client",
|
||||
style: TextStyle(
|
||||
fontFamily: "Gilroy-SemiBold",
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 20,
|
||||
height: 1.4,
|
||||
letterSpacing: 0.6,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
senderList.isEmpty
|
||||
? const Text("No documents from Client")
|
||||
: SizedBox(
|
||||
height: 110,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: senderList.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(width: 12),
|
||||
itemBuilder: (_, index) {
|
||||
final doc = senderList[index];
|
||||
final isImage = DownloadHelper.isImageFile(
|
||||
doc.filePath,
|
||||
);
|
||||
final isPdf =
|
||||
DownloadHelper.getFileExtension(doc.filePath) ==
|
||||
'pdf';
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
if (isImage) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ImagePreviewScreen(
|
||||
imageUrl: doc.filePath,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await DownloadHelper.downloadFile(
|
||||
doc.filePath,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: 100,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: isImage
|
||||
? ClipRRect(
|
||||
borderRadius:
|
||||
BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
doc.filePath,
|
||||
width: 100,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: isPdf
|
||||
? const Icon(
|
||||
Icons.picture_as_pdf,
|
||||
size: 40,
|
||||
color: Colors.red,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.insert_drive_file,
|
||||
size: 40,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImagePreviewScreen extends StatelessWidget {
|
||||
final String imageUrl;
|
||||
|
||||
const ImagePreviewScreen({super.key, required this.imageUrl});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
),
|
||||
body: Center(
|
||||
child: PhotoView(
|
||||
imageProvider: NetworkImage(imageUrl),
|
||||
backgroundDecoration: const BoxDecoration(color: Colors.black),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PdfPreviewScreen extends StatelessWidget {
|
||||
final String pdfUrl;
|
||||
final String fileName;
|
||||
|
||||
const PdfPreviewScreen({
|
||||
super.key,
|
||||
required this.pdfUrl,
|
||||
required this.fileName,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(fileName),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.picture_as_pdf, size: 80, color: Colors.red),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
fileName,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
"PDF Preview not available",
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
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/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);
|
||||
|
||||
// Show loading dialog
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => WillPopScope(
|
||||
onWillPop: () async => false,
|
||||
child: const Center(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text("Sending message..."),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
List<File> filesToUpload = [];
|
||||
|
||||
// ⭐⭐⭐ Save files to Send folder
|
||||
if (selectedFiles.isNotEmpty) {
|
||||
print("📤 Saving ${selectedFiles.length} files to Send folder...");
|
||||
|
||||
for (File file in selectedFiles) {
|
||||
try {
|
||||
// Try to save to Send folder, fallback to original if fails
|
||||
final savedPath = await DownloadHelper.saveToSendFolder(file);
|
||||
filesToUpload.add(File(savedPath));
|
||||
} catch (e) {
|
||||
print("⚠️ Could not save file, using original: $e");
|
||||
filesToUpload.add(file);
|
||||
}
|
||||
}
|
||||
|
||||
print("✅ Files prepared for upload: ${filesToUpload.length}");
|
||||
}
|
||||
|
||||
// Send message with files
|
||||
await ApiRepository().sendChatMessage(
|
||||
chatId: widget.chatId,
|
||||
messages: text,
|
||||
tagId: tagId,
|
||||
files: filesToUpload,
|
||||
);
|
||||
|
||||
// Refresh chat messages
|
||||
await ref.read(chatMessagesProvider(widget.chatId).notifier).refresh();
|
||||
ref.invalidate(chatDocumentProvider);
|
||||
print("✅ Message sent successfully with tagId: $tagId");
|
||||
|
||||
// Close loading dialog
|
||||
if (mounted) Navigator.pop(context);
|
||||
|
||||
// Clear input
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
messageCtrl.clear();
|
||||
selectedFiles.clear();
|
||||
_isSending = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Clear the tagged message
|
||||
ref.read(taggedMessageProvider.notifier).state = null;
|
||||
|
||||
// Notify parent to scroll to bottom
|
||||
if (widget.onMessageSent != null) {
|
||||
widget.onMessageSent!();
|
||||
}
|
||||
} catch (e) {
|
||||
print("❌ Error sending message: $e");
|
||||
|
||||
// Close loading dialog
|
||||
if (mounted) Navigator.pop(context);
|
||||
|
||||
setState(() => _isSending = false);
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:taxglide/consts/download_helper.dart';
|
||||
|
||||
class DownloadsScreen extends StatefulWidget {
|
||||
const DownloadsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DownloadsScreen> createState() => _DownloadsScreenState();
|
||||
}
|
||||
|
||||
class _DownloadsScreenState extends State<DownloadsScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
List<File> sentFiles = [];
|
||||
List<File> receivedFiles = [];
|
||||
bool isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_loadFiles();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadFiles() async {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final sent = await DownloadHelper.getSentFiles();
|
||||
final received = await DownloadHelper.getReceivedFiles();
|
||||
|
||||
setState(() {
|
||||
sentFiles = sent;
|
||||
receivedFiles = received;
|
||||
isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
print("Error loading files: $e");
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteFile(String filePath, bool isSent) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text("Delete File"),
|
||||
content: const Text("Are you sure you want to delete this file?"),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text("Delete"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
final success = await DownloadHelper.deleteFile(filePath);
|
||||
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("File deleted successfully"),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
_loadFiles();
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Failed to delete file"),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _getFileName(String path) {
|
||||
return path.split('/').last;
|
||||
}
|
||||
|
||||
String _getFileSize(File file) {
|
||||
final bytes = file.lengthSync();
|
||||
if (bytes < 1024) return "$bytes B";
|
||||
if (bytes < 1024 * 1024) return "${(bytes / 1024).toStringAsFixed(1)} KB";
|
||||
return "${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getFileIconColor(String path) {
|
||||
final ext = path.split('.').last.toLowerCase();
|
||||
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext)) {
|
||||
return Colors.blue;
|
||||
} else if (ext == 'pdf') {
|
||||
return Colors.red;
|
||||
} else if (['doc', 'docx'].contains(ext)) {
|
||||
return Colors.indigo;
|
||||
} else if (['xls', 'xlsx'].contains(ext)) {
|
||||
return Colors.green;
|
||||
} else {
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildFileList(List<File> files, bool isSent) {
|
||||
if (isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (files.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
isSent ? Icons.upload_file : Icons.download,
|
||||
size: 80,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
isSent ? "No sent files" : "No downloaded files",
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.grey[600],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
isSent
|
||||
? "Files you upload will appear here"
|
||||
: "Downloaded files will appear here",
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadFiles,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: files.length,
|
||||
itemBuilder: (context, index) {
|
||||
final file = files[index];
|
||||
final fileName = _getFileName(file.path);
|
||||
final fileSize = _getFileSize(file);
|
||||
final isImage = DownloadHelper.isImageFile(file.path);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => DownloadHelper.openDownloadedFile(file.path),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
// File icon or image preview
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: _getFileIconColor(file.path).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: isImage
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(file, fit: BoxFit.cover),
|
||||
)
|
||||
: Icon(
|
||||
_getFileIcon(file.path),
|
||||
size: 30,
|
||||
color: _getFileIconColor(file.path),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// File info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
fileName,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
fileSize,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Delete button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
color: Colors.red,
|
||||
onPressed: () => _deleteFile(file.path, isSent),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
"Downloads",
|
||||
style: TextStyle(
|
||||
fontFamily: "Gilroy-SemiBold",
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 1,
|
||||
foregroundColor: Colors.black,
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: const Color(0xFF630A73),
|
||||
unselectedLabelColor: Colors.grey,
|
||||
indicatorColor: const Color(0xFF630A73),
|
||||
tabs: [
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.upload, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Text("Sent (${sentFiles.length})"),
|
||||
],
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.download, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Text("Received (${receivedFiles.length})"),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildFileList(sentFiles, true),
|
||||
_buildFileList(receivedFiles, false),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _loadFiles,
|
||||
backgroundColor: const Color(0xFF630A73),
|
||||
child: const Icon(Icons.refresh, color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
// ⭐⭐⭐ UPDATED SWIPEABLE MESSAGE BUBBLE WITH IMAGE GRID VIEWER ⭐⭐⭐
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:taxglide/consts/download_helper.dart';
|
||||
import 'package:taxglide/model/chat_model.dart';
|
||||
|
||||
class SwipeableMessageBubble extends StatefulWidget {
|
||||
final MessageModel message;
|
||||
final DateTime msgDate;
|
||||
final String Function(DateTime) formatTime;
|
||||
final Widget Function(int, int) buildTick;
|
||||
final VoidCallback onSwipe;
|
||||
final Function(int?)? onParentTagTap;
|
||||
final Function(List<String> imageUrls, int initialIndex)?
|
||||
onImageTap; // 🖼️ NEW
|
||||
|
||||
const SwipeableMessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.msgDate,
|
||||
required this.formatTime,
|
||||
required this.buildTick,
|
||||
required this.onSwipe,
|
||||
this.onParentTagTap,
|
||||
this.onImageTap, // 🖼️ NEW
|
||||
});
|
||||
|
||||
@override
|
||||
State<SwipeableMessageBubble> createState() => SwipeableMessageBubbleState();
|
||||
}
|
||||
|
||||
class SwipeableMessageBubbleState extends State<SwipeableMessageBubble>
|
||||
with SingleTickerProviderStateMixin {
|
||||
double _dragExtent = 0;
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _animation;
|
||||
final Map<String, bool> _downloadingFiles = {};
|
||||
final Map<String, bool> _downloadedFiles = {};
|
||||
bool _isHighlighted = false;
|
||||
|
||||
void highlight() {
|
||||
if (!mounted) return;
|
||||
// Reset first to ensure it re-triggers if already highlighted
|
||||
setState(() => _isHighlighted = false);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isHighlighted = true);
|
||||
Future.delayed(const Duration(milliseconds: 1500), () {
|
||||
if (mounted) setState(() => _isHighlighted = false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
_animation = Tween<double>(begin: 0, end: 0).animate(_controller)
|
||||
..addListener(() {
|
||||
setState(() {
|
||||
_dragExtent = _animation.value;
|
||||
});
|
||||
});
|
||||
|
||||
_checkDownloadedFiles();
|
||||
}
|
||||
|
||||
Future<void> _checkDownloadedFiles() async {
|
||||
for (var doc in widget.message.uploadedDocuments) {
|
||||
final existingPath = await DownloadHelper.checkFileExistsInReceived(
|
||||
doc.filePath,
|
||||
);
|
||||
if (existingPath != null && mounted) {
|
||||
setState(() {
|
||||
_downloadedFiles[doc.fileName] = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) {
|
||||
if (!mounted) return;
|
||||
|
||||
final isUserMessage = widget.message.chatBy == "user";
|
||||
final delta = details.primaryDelta ?? 0;
|
||||
|
||||
if ((isUserMessage && delta < 0) || (!isUserMessage && delta > 0)) {
|
||||
setState(() {
|
||||
_dragExtent += delta;
|
||||
_dragExtent = _dragExtent.clamp(
|
||||
isUserMessage ? -80.0 : 0.0,
|
||||
isUserMessage ? 0.0 : 80.0,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDragEnd(DragEndDetails details) {
|
||||
if (!mounted) return;
|
||||
|
||||
final threshold = 60.0;
|
||||
|
||||
if (_dragExtent.abs() > threshold) {
|
||||
widget.onSwipe();
|
||||
}
|
||||
|
||||
_animation = Tween<double>(
|
||||
begin: _dragExtent,
|
||||
end: 0,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
|
||||
|
||||
_controller.reset();
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
// 🖼️ NEW: Handle image tap - open grid viewer
|
||||
void _handleImageTap(int tappedIndex) {
|
||||
if (widget.onImageTap == null) return;
|
||||
|
||||
// Get all image URLs from uploaded documents
|
||||
final imageUrls = widget.message.uploadedDocuments
|
||||
.where((doc) => DownloadHelper.isImageFile(doc.fileName))
|
||||
.map((doc) => doc.filePath)
|
||||
.toList();
|
||||
|
||||
if (imageUrls.isNotEmpty) {
|
||||
widget.onImageTap!(imageUrls, tappedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleFileView(String url, String fileName) async {
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
print("👁️ Opening file: $fileName");
|
||||
|
||||
final existingPath = await DownloadHelper.checkFileExistsInReceived(url);
|
||||
|
||||
if (existingPath != null) {
|
||||
await DownloadHelper.openDownloadedFile(existingPath);
|
||||
} else {
|
||||
final sendFolder = await DownloadHelper.getSendFolder();
|
||||
final localPath = '${sendFolder.path}/$fileName';
|
||||
final file = File(localPath);
|
||||
|
||||
if (await file.exists()) {
|
||||
await DownloadHelper.openDownloadedFile(localPath);
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("File not found locally"),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print("❌ View error: $e");
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("Could not open file: $e"),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleFileDownload(String url, String fileName) async {
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_downloadingFiles[fileName] = true;
|
||||
});
|
||||
|
||||
try {
|
||||
print("🔗 Downloading from: $url");
|
||||
final result = await DownloadHelper.downloadFileToReceived(url);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (result['success'] == true) {
|
||||
setState(() {
|
||||
_downloadedFiles[fileName] = true;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
if (result['isNewDownload'] == false) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("Opening $fileName"),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("Downloaded $fileName"),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print("❌ Download error: $e");
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("Download failed: $e"),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_downloadingFiles[fileName] = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleDownloadedFileView(String url, String fileName) async {
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final existingPath = await DownloadHelper.checkFileExistsInReceived(url);
|
||||
if (existingPath != null) {
|
||||
await DownloadHelper.openDownloadedFile(existingPath);
|
||||
}
|
||||
} catch (e) {
|
||||
print("❌ View error: $e");
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("Could not open file: $e"),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isUserMessage = widget.message.chatBy == "user";
|
||||
final hasDocuments = widget.message.uploadedDocuments.isNotEmpty;
|
||||
|
||||
print("💬 Building message bubble:");
|
||||
print(" - ID: ${widget.message.id}");
|
||||
print(" - chatBy: ${widget.message.chatBy}");
|
||||
print(
|
||||
" - uploadedDocuments count: ${widget.message.uploadedDocuments.length}",
|
||||
);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
if (_dragExtent.abs() > 10)
|
||||
Positioned(
|
||||
right: isUserMessage ? 20 : null,
|
||||
left: isUserMessage ? null : 20,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.reply_rounded,
|
||||
color: Colors.grey[600],
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onHorizontalDragUpdate: _handleDragUpdate,
|
||||
onHorizontalDragEnd: _handleDragEnd,
|
||||
child: Transform.translate(
|
||||
offset: Offset(_dragExtent, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.message.user != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 5, right: 5),
|
||||
child: Align(
|
||||
alignment: isUserMessage
|
||||
? Alignment.centerRight
|
||||
: Alignment.centerLeft,
|
||||
child: Text(
|
||||
widget.message.user!.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.message.parentTag != null)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (widget.onParentTagTap != null) {
|
||||
widget.onParentTagTap!(
|
||||
widget.message.parentTag?.id ?? widget.message.tagId,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Align(
|
||||
alignment: isUserMessage
|
||||
? Alignment.centerRight
|
||||
: Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(
|
||||
bottom: 4,
|
||||
left: 5,
|
||||
right: 5,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.60,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF630A73).withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 3,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF630A73),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.message.parentTag!.type == "file")
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.insert_drive_file,
|
||||
size: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget
|
||||
.message
|
||||
.parentTag!
|
||||
.fileName ??
|
||||
"File",
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
widget.message.parentTag!.message,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: isUserMessage
|
||||
? Alignment.centerRight
|
||||
: Alignment.centerLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.70,
|
||||
),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 10,
|
||||
horizontal: 14,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _isHighlighted
|
||||
? Colors.green.withOpacity(0.2)
|
||||
: (isUserMessage
|
||||
? const Color(0xFFFFF8E2)
|
||||
: Colors.white),
|
||||
border: Border.all(
|
||||
color: _isHighlighted
|
||||
? Colors.green.withOpacity(0.5)
|
||||
: (isUserMessage
|
||||
? const Color(0xFFF9D9C8)
|
||||
: const Color(0xFFE2E2E2)),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
boxShadow: isUserMessage
|
||||
? []
|
||||
: const [
|
||||
BoxShadow(
|
||||
color: Color(0x40979797),
|
||||
blurRadius: 3.6,
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ⭐⭐⭐ DISPLAY uploadedDocuments WITH IMAGE GRID SUPPORT
|
||||
if (hasDocuments)
|
||||
...widget.message.uploadedDocuments.asMap().entries.map((
|
||||
entry,
|
||||
) {
|
||||
final index = entry.key;
|
||||
final doc = entry.value;
|
||||
final isImage = DownloadHelper.isImageFile(
|
||||
doc.fileName,
|
||||
);
|
||||
final isDownloading =
|
||||
_downloadingFiles[doc.fileName] ?? false;
|
||||
final isDownloaded =
|
||||
_downloadedFiles[doc.fileName] ?? false;
|
||||
|
||||
// 🖼️ Calculate image index (only count images before this one)
|
||||
final imageIndex = widget
|
||||
.message
|
||||
.uploadedDocuments
|
||||
.sublist(0, index)
|
||||
.where(
|
||||
(d) =>
|
||||
DownloadHelper.isImageFile(d.fileName),
|
||||
)
|
||||
.length;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: isImage
|
||||
? () =>
|
||||
_handleImageTap(
|
||||
imageIndex,
|
||||
) // 🖼️ Open grid viewer
|
||||
: (isUserMessage
|
||||
? () => _handleFileView(
|
||||
doc.filePath,
|
||||
doc.fileName,
|
||||
)
|
||||
: null),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: isImage
|
||||
? Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius:
|
||||
BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
doc.filePath,
|
||||
width: 150,
|
||||
height: 100,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(context, error, stack) {
|
||||
return Container(
|
||||
width: 200,
|
||||
height: 150,
|
||||
color: Colors.grey[300],
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.broken_image,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// ⭐ Download/View icon for receiver messages
|
||||
if (!isUserMessage)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: GestureDetector(
|
||||
onTap: isDownloading
|
||||
? null
|
||||
: isDownloaded
|
||||
? () =>
|
||||
_handleDownloadedFileView(
|
||||
doc.filePath,
|
||||
doc.fileName,
|
||||
)
|
||||
: () =>
|
||||
_handleFileDownload(
|
||||
doc.filePath,
|
||||
doc.fileName,
|
||||
),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black
|
||||
.withOpacity(0.6),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: isDownloading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<
|
||||
Color
|
||||
>(
|
||||
Colors
|
||||
.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
isDownloaded
|
||||
? Icons
|
||||
.visibility
|
||||
: Icons
|
||||
.download,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(
|
||||
8,
|
||||
),
|
||||
border: Border.all(
|
||||
color: Colors.grey[300]!,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.insert_drive_file,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
doc.fileName,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight:
|
||||
FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
doc.fileType
|
||||
.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: isDownloading
|
||||
? null
|
||||
: () => isUserMessage
|
||||
? _handleFileView(
|
||||
doc.filePath,
|
||||
doc.fileName,
|
||||
)
|
||||
: isDownloaded
|
||||
? _handleDownloadedFileView(
|
||||
doc.filePath,
|
||||
doc.fileName,
|
||||
)
|
||||
: _handleFileDownload(
|
||||
doc.filePath,
|
||||
doc.fileName,
|
||||
),
|
||||
child: isDownloading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child:
|
||||
CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
isUserMessage
|
||||
? Icons.open_in_new
|
||||
: isDownloaded
|
||||
? Icons.visibility
|
||||
: Icons.download,
|
||||
color: Colors.grey[600],
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
if (widget.message.message.isNotEmpty)
|
||||
Text(
|
||||
widget.message.message,
|
||||
style: const TextStyle(fontSize: 15),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
widget.formatTime(widget.msgDate),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
widget.buildTick(
|
||||
widget.message.isDelivered,
|
||||
widget.message.isRead,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,296 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
/// WebSocket service for real-time chat communication
|
||||
class ChatWebSocketService {
|
||||
static final ChatWebSocketService _instance =
|
||||
ChatWebSocketService._internal();
|
||||
factory ChatWebSocketService() => _instance;
|
||||
ChatWebSocketService._internal();
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _subscription;
|
||||
Timer? _pingTimer;
|
||||
Timer? _reconnectTimer;
|
||||
|
||||
bool _isConnected = false;
|
||||
bool _isIntentionalClose = false;
|
||||
int _reconnectAttempts = 0;
|
||||
static const int _maxReconnectAttempts = 5;
|
||||
static const Duration _reconnectDelay = Duration(seconds: 3);
|
||||
|
||||
// Callbacks
|
||||
Function(Map<String, dynamic>)? onMessageReceived;
|
||||
Function()? onConnected;
|
||||
Function()? onDisconnected;
|
||||
Function(dynamic error)? onError;
|
||||
|
||||
String? _currentChatId;
|
||||
String? _wsUrl;
|
||||
|
||||
/// Check if WebSocket is currently connected
|
||||
bool get isConnected => _isConnected;
|
||||
|
||||
/// Get current chat ID
|
||||
String? get currentChatId => _currentChatId;
|
||||
|
||||
/// Connect to WebSocket for a specific chat
|
||||
Future<void> connect({
|
||||
required String chatId,
|
||||
String baseUrl = "wss://taxglide.amrithaa.net:443/reverb",
|
||||
String appKey = "2yj0lyzc9ylw2h03ts6i",
|
||||
}) async {
|
||||
try {
|
||||
// Close existing connection if any
|
||||
await disconnect();
|
||||
|
||||
_currentChatId = chatId;
|
||||
_wsUrl = "$baseUrl/app/$appKey?protocol=7&client=js&version=1.0";
|
||||
_isIntentionalClose = false;
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
await _establishConnection();
|
||||
} catch (e) {
|
||||
debugPrint("❌ Connection error: $e");
|
||||
onError?.call(e);
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/// Establish WebSocket connection
|
||||
Future<void> _establishConnection() async {
|
||||
try {
|
||||
debugPrint("🔌 Connecting to: $_wsUrl");
|
||||
|
||||
_channel = WebSocketChannel.connect(Uri.parse(_wsUrl!));
|
||||
|
||||
// Listen to messages
|
||||
_subscription = _channel!.stream.listen(
|
||||
_handleMessage,
|
||||
onError: _handleError,
|
||||
onDone: _handleDisconnection,
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
// Wait a bit for connection to establish
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
_isConnected = true;
|
||||
_reconnectAttempts = 0;
|
||||
debugPrint("✅ WebSocket connected");
|
||||
|
||||
// Subscribe to chat channel
|
||||
_subscribeToChannel();
|
||||
|
||||
// Start ping timer
|
||||
_startPingTimer();
|
||||
|
||||
onConnected?.call();
|
||||
} catch (e) {
|
||||
debugPrint("❌ Failed to establish connection: $e");
|
||||
_isConnected = false;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle incoming messages
|
||||
void _handleMessage(dynamic data) {
|
||||
try {
|
||||
debugPrint("📨 Received: $data");
|
||||
|
||||
final Map<String, dynamic> message = json.decode(data);
|
||||
|
||||
// Handle Pusher ping
|
||||
if (message['event'] == 'pusher:ping') {
|
||||
_sendPong();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle connection established
|
||||
if (message['event'] == 'pusher:connection_established') {
|
||||
debugPrint("🔗 Connection established");
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle subscription succeeded
|
||||
if (message['event'] == 'pusher_internal:subscription_succeeded') {
|
||||
debugPrint("✅ Subscribed to chat.${_currentChatId}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle custom events (your chat messages)
|
||||
if (message['event'] != null &&
|
||||
!message['event'].toString().startsWith('pusher')) {
|
||||
onMessageReceived?.call(message);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("❌ Error parsing message: $e");
|
||||
onError?.call(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle WebSocket errors
|
||||
void _handleError(dynamic error) {
|
||||
debugPrint("❌ WebSocket error: $error");
|
||||
_isConnected = false;
|
||||
onError?.call(error);
|
||||
|
||||
if (!_isIntentionalClose) {
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle disconnection
|
||||
void _handleDisconnection() {
|
||||
debugPrint("🔌 WebSocket disconnected");
|
||||
_isConnected = false;
|
||||
_stopPingTimer();
|
||||
onDisconnected?.call();
|
||||
|
||||
if (!_isIntentionalClose) {
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to chat channel
|
||||
void _subscribeToChannel() {
|
||||
if (_currentChatId == null) return;
|
||||
|
||||
final subscribeMessage = {
|
||||
'event': 'pusher:subscribe',
|
||||
'data': {'channel': 'chat.$_currentChatId'},
|
||||
};
|
||||
|
||||
_sendMessage(subscribeMessage);
|
||||
debugPrint("📡 Subscribing to chat.$_currentChatId");
|
||||
}
|
||||
|
||||
/// Send pong response to ping
|
||||
void _sendPong() {
|
||||
final pongMessage = {'event': 'pusher:pong', 'data': {}};
|
||||
_sendMessage(pongMessage);
|
||||
debugPrint("🏓 Pong sent");
|
||||
}
|
||||
|
||||
/// Start periodic ping timer
|
||||
void _startPingTimer() {
|
||||
_pingTimer?.cancel();
|
||||
_pingTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
|
||||
if (_isConnected) {
|
||||
// Server will send ping, we just respond with pong
|
||||
debugPrint("💓 Heartbeat check");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop ping timer
|
||||
void _stopPingTimer() {
|
||||
_pingTimer?.cancel();
|
||||
_pingTimer = null;
|
||||
}
|
||||
|
||||
/// Schedule reconnection attempt
|
||||
void _scheduleReconnect() {
|
||||
if (_isIntentionalClose) return;
|
||||
if (_reconnectAttempts >= _maxReconnectAttempts) {
|
||||
debugPrint("❌ Max reconnection attempts reached");
|
||||
onError?.call(
|
||||
"Failed to reconnect after $_maxReconnectAttempts attempts",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectAttempts++;
|
||||
|
||||
debugPrint(
|
||||
"🔄 Scheduling reconnect attempt $_reconnectAttempts in ${_reconnectDelay.inSeconds}s",
|
||||
);
|
||||
|
||||
_reconnectTimer = Timer(_reconnectDelay, () async {
|
||||
if (!_isIntentionalClose && _wsUrl != null && _currentChatId != null) {
|
||||
debugPrint("🔄 Attempting to reconnect...");
|
||||
try {
|
||||
await _establishConnection();
|
||||
} catch (e) {
|
||||
debugPrint("❌ Reconnect failed: $e");
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Send a message through WebSocket
|
||||
void _sendMessage(Map<String, dynamic> message) {
|
||||
if (_channel == null || !_isConnected) {
|
||||
debugPrint("⚠️ Cannot send message: Not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final jsonMessage = json.encode(message);
|
||||
_channel!.sink.add(jsonMessage);
|
||||
debugPrint("📤 Sent: $jsonMessage");
|
||||
} catch (e) {
|
||||
debugPrint("❌ Error sending message: $e");
|
||||
onError?.call(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a custom event to the channel
|
||||
void sendEvent({
|
||||
required String eventName,
|
||||
required Map<String, dynamic> data,
|
||||
}) {
|
||||
final message = {
|
||||
'event': eventName,
|
||||
'data': data,
|
||||
'channel': 'chat.$_currentChatId',
|
||||
};
|
||||
_sendMessage(message);
|
||||
}
|
||||
|
||||
/// Disconnect from WebSocket
|
||||
Future<void> disconnect() async {
|
||||
_isIntentionalClose = true;
|
||||
_reconnectTimer?.cancel();
|
||||
_stopPingTimer();
|
||||
|
||||
await _subscription?.cancel();
|
||||
await _channel?.sink.close();
|
||||
|
||||
_channel = null;
|
||||
_subscription = null;
|
||||
_isConnected = false;
|
||||
_currentChatId = null;
|
||||
|
||||
debugPrint("🔌 WebSocket disconnected intentionally");
|
||||
}
|
||||
|
||||
/// Switch to a different chat channel
|
||||
Future<void> switchChannel(String newChatId) async {
|
||||
if (newChatId == _currentChatId) return;
|
||||
|
||||
debugPrint("🔄 Switching from chat.$_currentChatId to chat.$newChatId");
|
||||
|
||||
// Unsubscribe from current channel
|
||||
if (_currentChatId != null) {
|
||||
final unsubscribeMessage = {
|
||||
'event': 'pusher:unsubscribe',
|
||||
'data': {'channel': 'chat.$_currentChatId'},
|
||||
};
|
||||
_sendMessage(unsubscribeMessage);
|
||||
}
|
||||
|
||||
// Update chat ID and subscribe to new channel
|
||||
_currentChatId = newChatId;
|
||||
_subscribeToChannel();
|
||||
}
|
||||
|
||||
/// Dispose and cleanup
|
||||
void dispose() {
|
||||
disconnect();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user