2026-04-11

This commit is contained in:
Sarankumar committed 2026-04-11 10:21:31 +05:30
commit 27e27fb70b
270 files changed
+30296

No files matched your search

+373
View File
@@ -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),
),
],
),
),
);
}
}
+569
View File
@@ -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();
}
}
+323
View File
@@ -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),
),
);
}
}
+690
View File
@@ -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
+296
View File
@@ -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();
}
}