diff --git a/lib/src/client_proposal_management/data/chat_controller.dart b/lib/src/client_proposal_management/data/chat_controller.dart new file mode 100644 index 0000000..ad5f077 --- /dev/null +++ b/lib/src/client_proposal_management/data/chat_controller.dart @@ -0,0 +1,67 @@ +// First, create a ChatController +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:home_front_pk/src/client_proposal_management/data/client_proposal_controller.dart'; +import 'package:home_front_pk/src/client_proposal_management/data/client_proposal_repo.dart'; +import 'package:home_front_pk/src/features/user_job_post/data/image_upload_repo.dart'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +class ChatController extends StateNotifier> { + final ClientProposalRepository _repository; + final StorageRepository _storageRepository; + + ChatController({ + required ClientProposalRepository repository, + required StorageRepository storageRepository, + }) : _repository = repository, + _storageRepository = storageRepository, + super(const AsyncValue.data(null)); + + Future uploadChatImage(String chatId, File file) async { + try { + state = const AsyncValue.loading(); + final url = await _storageRepository.uploadChatImage(chatId, file); + state = const AsyncValue.data(null); + return url; + } catch (e, st) { + state = AsyncValue.error(e, st); + rethrow; + } + } + + Future sendMessage(String chatId, String content, String type) async { + try { + state = const AsyncValue.loading(); + await _repository.sendMessage(chatId, content, type); + state = const AsyncValue.data(null); + } catch (e, st) { + state = AsyncValue.error(e, st); + rethrow; + } + } + + Future createChatRoom(String jobId, String constructorId) async { + try { + state = const AsyncValue.loading(); + final chatId = await _repository.createChatRoom(jobId, constructorId); + state = const AsyncValue.data(null); + return chatId; + } catch (e, st) { + state = AsyncValue.error(e, st); + rethrow; + } + } +} + +// Add providers +final chatControllerProvider = + StateNotifierProvider>((ref) { + final repository = ref.watch(clientProposalRepositoryProvider); + final storageRepository = ref.watch(storageRepositoryProvider); + return ChatController( + repository: repository, + storageRepository: storageRepository, + ); +}); diff --git a/lib/src/client_proposal_management/data/client_proposal_controller.dart b/lib/src/client_proposal_management/data/client_proposal_controller.dart new file mode 100644 index 0000000..9de382a --- /dev/null +++ b/lib/src/client_proposal_management/data/client_proposal_controller.dart @@ -0,0 +1,70 @@ +// Providers +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:home_front_pk/src/client_proposal_management/data/client_proposal_repo.dart'; +import 'package:home_front_pk/src/client_proposal_management/domain/client_proposal_model.dart'; +import 'package:home_front_pk/src/features/user_job_post/data/image_upload_repo.dart'; + +final clientProposalRepositoryProviderWithStorage = + Provider((ref) { + return ClientProposalRepository(); +}); + +// Stream provider for job proposals +final jobProposalsProvider = + StreamProvider.family, String>((ref, jobId) { + final repository = ref.watch(clientProposalRepositoryProviderWithStorage); + return repository.getJobProposals(jobId); +}); + +// Stream provider for all proposals across all jobs +final allJobProposalsProvider = + StreamProvider>>((ref) { + final repository = ref.watch(clientProposalRepositoryProvider); + return repository.getAllJobProposals(); +}); + +// Controller +class ClientProposalController extends StateNotifier> { + final ClientProposalRepository _repository; + + ClientProposalController(this._repository) + : super(const AsyncValue.data(null)); + + Future acceptProposal(String jobId, String proposalId) async { + state = const AsyncValue.loading(); + try { + await _repository.acceptProposal(jobId, proposalId); + state = const AsyncValue.data(null); + } catch (e, st) { + state = AsyncValue.error(e, st); + rethrow; + } + } + + Future rejectProposal(String proposalId, {String? reason}) async { + state = const AsyncValue.loading(); + try { + await _repository.rejectProposal(proposalId, reason: reason); + state = const AsyncValue.data(null); + } catch (e, st) { + state = AsyncValue.error(e, st); + rethrow; + } + } +} + +final storageRepositoryProvider = Provider((ref) { + return StorageRepository(); +}); + +final clientProposalRepositoryProvider = + Provider((ref) { + final storageRepo = ref.watch(storageRepositoryProvider); + return ClientProposalRepository(); +}); + +final clientProposalControllerProvider = + StateNotifierProvider>((ref) { + final repository = ref.watch(clientProposalRepositoryProvider); + return ClientProposalController(repository); +}); diff --git a/lib/src/client_proposal_management/data/client_proposal_repo.dart b/lib/src/client_proposal_management/data/client_proposal_repo.dart new file mode 100644 index 0000000..b794919 --- /dev/null +++ b/lib/src/client_proposal_management/data/client_proposal_repo.dart @@ -0,0 +1,367 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:home_front_pk/src/client_proposal_management/data/client_proposal_controller.dart'; +import 'package:home_front_pk/src/client_proposal_management/domain/chat_message.dart'; +import 'package:home_front_pk/src/client_proposal_management/domain/client_proposal_model.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +class ClientProposalRepository { + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + final FirebaseAuth _auth = FirebaseAuth.instance; + + String? get currentUserId => _auth.currentUser?.uid; + + // Get all proposals for a specific job + Stream> getJobProposals(String jobId) { + return _firestore + .collection('proposals') + .where('jobId', isEqualTo: jobId) + .orderBy('createdAt', descending: true) + .snapshots() + .asyncMap((snapshot) async { + List proposals = []; + for (var doc in snapshot.docs) { + // Get constructor details + final constructorDoc = await _firestore + .collection('users') + .doc(doc.data()['constructorId']) + .get(); + + if (constructorDoc.exists) { + final constructorData = constructorDoc.data()!; + final proposal = ClientProposal.fromJson({ + ...doc.data(), + 'id': doc.id, + 'constructorDetails': constructorData, + }); + proposals.add(proposal); + } + } + return proposals; + }); + } + + Future acceptProposal(String jobId, String proposalId) async { + try { + // First get all pending proposals for this job + final proposalsQuery = await _firestore + .collection('proposals') + .where('jobId', isEqualTo: jobId) + .where('status', isEqualTo: 'pending') + .get(); + + // Start the batch write + final batch = _firestore.batch(); + + // Update the accepted proposal + batch.update( + _firestore.collection('proposals').doc(proposalId), + { + 'status': 'accepted', + 'respondedAt': DateTime.now().toIso8601String(), + }, + ); + + // Reject all other proposals + for (var doc in proposalsQuery.docs) { + if (doc.id != proposalId) { + batch.update( + doc.reference, + { + 'status': 'rejected', + 'respondedAt': DateTime.now().toIso8601String(), + }, + ); + } + } + + // Update job status + batch.update( + _firestore.collection('jobs').doc(jobId), + { + 'status': 'in_progress', + 'acceptedProposalId': proposalId, + }, + ); + + // Commit the batch + await batch.commit(); + } catch (e) { + throw Exception('Failed to accept proposal: $e'); + } + } + + // Optional: If you need to get all proposals for a job + Future> getAllProposalsForJob(String jobId) async { + try { + final querySnapshot = await _firestore + .collection('proposals') + .where('jobId', isEqualTo: jobId) + .get(); + + List proposals = []; + for (var doc in querySnapshot.docs) { + final constructorDoc = await _firestore + .collection('users') + .doc(doc.data()['constructorId']) + .get(); + + if (constructorDoc.exists) { + proposals.add( + ClientProposal.fromJson({ + ...doc.data(), + 'id': doc.id, + 'constructorDetails': constructorDoc.data(), + }), + ); + } + } + + return proposals; + } catch (e) { + throw Exception('Failed to get proposals: $e'); + } + } + + // Reject a proposal + Future rejectProposal(String proposalId, {String? reason}) async { + try { + await _firestore.collection('proposals').doc(proposalId).update({ + 'status': 'rejected', + 'clientResponse': reason, + 'respondedAt': DateTime.now().toIso8601String(), + }); + } catch (e) { + throw Exception('Failed to reject proposal: $e'); + } + } + + // Get all proposals for current client's jobs + Stream>> getAllJobProposals() { + if (currentUserId == null) { + throw Exception('No user logged in'); + } + + return _firestore + .collection('jobs') + .where('userId', isEqualTo: currentUserId) + .snapshots() + .asyncMap((jobsSnapshot) async { + Map> allProposals = {}; + + for (var jobDoc in jobsSnapshot.docs) { + final proposals = await _firestore + .collection('proposals') + .where('jobId', isEqualTo: jobDoc.id) + .get(); + + List jobProposals = []; + for (var proposalDoc in proposals.docs) { + final constructorDoc = await _firestore + .collection('users') + .doc(proposalDoc.data()['constructorId']) + .get(); + + if (constructorDoc.exists) { + final proposal = ClientProposal.fromJson({ + ...proposalDoc.data(), + 'id': proposalDoc.id, + 'constructorDetails': constructorDoc.data(), + }); + jobProposals.add(proposal); + } + } + + if (jobProposals.isNotEmpty) { + allProposals[jobDoc.id] = jobProposals; + } + } + + return allProposals; + }); + } + + // Get constructor details + Future getConstructorDetails(String constructorId) async { + try { + final doc = await _firestore.collection('users').doc(constructorId).get(); + + if (!doc.exists) { + throw Exception('Constructor not found'); + } + + return ConstructorDetails.fromJson({ + ...doc.data()!, + 'id': doc.id, + }); + } catch (e) { + throw Exception('Failed to get constructor details: $e'); + } + } + + Future approveMilestone(String proposalId, int milestoneIndex) async { + try { + final proposalDoc = + await _firestore.collection('proposals').doc(proposalId).get(); + + if (!proposalDoc.exists) { + throw Exception('Proposal not found'); + } + + List milestones = proposalDoc.data()?['milestones'] ?? []; + if (milestoneIndex >= milestones.length) { + throw Exception('Invalid milestone index'); + } + + // Update the milestone status + milestones[milestoneIndex]['status'] = 'completed'; + milestones[milestoneIndex]['completedAt'] = + DateTime.now().toIso8601String(); + + await _firestore.collection('proposals').doc(proposalId).update({ + 'milestones': milestones, + }); + } catch (e) { + throw Exception('Failed to approve milestone: $e'); + } + } + + // Add method to create a chat room + Future createChatRoom(String jobId, String constructorId) async { + try { + if (currentUserId == null) { + throw Exception('No user logged in'); + } + + final chatDoc = await _firestore.collection('chats').add({ + 'jobId': jobId, + 'clientId': currentUserId, + 'constructorId': constructorId, + 'createdAt': DateTime.now().toIso8601String(), + 'lastMessage': null, + 'lastMessageTime': null, + }); + + return chatDoc.id; + } catch (e) { + throw Exception('Failed to create chat room: $e'); + } + } + + // Get chat messages + Stream> getChatMessages(String chatId) { + return _firestore + .collection('chats') + .doc(chatId) + .collection('messages') + .orderBy('timestamp', descending: true) + .snapshots() + .map((snapshot) => snapshot.docs + .map((doc) => ChatMessage.fromJson({ + ...doc.data(), + 'id': doc.id, + })) + .toList()); + } + + // Send a message + Future sendMessage(String chatId, String content, String type) async { + try { + if (currentUserId == null) { + throw Exception('No user logged in'); + } + + final message = ChatMessage( + id: '', + senderId: currentUserId!, + content: content, + type: type, + timestamp: DateTime.now(), + ); + + await _firestore + .collection('chats') + .doc(chatId) + .collection('messages') + .add(message.toJson()); + + // Update last message in chat room + await _firestore.collection('chats').doc(chatId).update({ + 'lastMessage': content, + 'lastMessageTime': DateTime.now().toIso8601String(), + }); + } catch (e) { + throw Exception('Failed to send message: $e'); + } + } + + Future getAcceptedProposal(String jobId) async { + try { + final querySnapshot = await _firestore + .collection('proposals') + .where('jobId', isEqualTo: jobId) + .where('status', isEqualTo: 'accepted') + .limit(1) + .get(); + + if (querySnapshot.docs.isEmpty) { + return null; + } + + final doc = querySnapshot.docs.first; + + // Get constructor details + final constructorDoc = await _firestore + .collection('users') + .doc(doc.data()['constructorId']) + .get(); + + if (!constructorDoc.exists) { + throw Exception('Constructor not found'); + } + + return ClientProposal.fromJson({ + ...doc.data(), + 'id': doc.id, + 'constructorDetails': constructorDoc.data(), + }); + } catch (e) { + throw Exception('Failed to get accepted proposal: $e'); + } + } +} + +// Add a provider for accepted proposal +final acceptedProposalProvider = FutureProvider.family( + (ref, jobId) { + final repository = ref.watch(clientProposalRepositoryProvider); + return repository.getAcceptedProposal(jobId); + }, +); + +extension JobManagement on ClientProposalRepository { + Future deleteJob(String jobId) async { + try { + // Start a batch write + final batch = _firestore.batch(); + + // Delete the job + batch.delete(_firestore.collection('jobs').doc(jobId)); + + // Get and delete all related proposals + final proposals = await _firestore + .collection('proposals') + .where('jobId', isEqualTo: jobId) + .get(); + + for (var doc in proposals.docs) { + batch.delete(doc.reference); + } + + // Commit the batch + await batch.commit(); + } catch (e) { + throw Exception('Failed to delete job: $e'); + } + } +} diff --git a/lib/src/client_proposal_management/domain/chat_message.dart b/lib/src/client_proposal_management/domain/chat_message.dart new file mode 100644 index 0000000..d0981a1 --- /dev/null +++ b/lib/src/client_proposal_management/domain/chat_message.dart @@ -0,0 +1,35 @@ +class ChatMessage { + final String id; + final String senderId; + final String content; + final String type; // 'text', 'image', etc. + final DateTime timestamp; + + ChatMessage({ + required this.id, + required this.senderId, + required this.content, + required this.type, + required this.timestamp, + }); + + factory ChatMessage.fromJson(Map json) { + return ChatMessage( + id: json['id'] ?? '', + senderId: json['senderId'] ?? '', + content: json['content'] ?? '', + type: json['type'] ?? 'text', + timestamp: + DateTime.parse(json['timestamp'] ?? DateTime.now().toIso8601String()), + ); + } + + Map toJson() { + return { + 'senderId': senderId, + 'content': content, + 'type': type, + 'timestamp': timestamp.toIso8601String(), + }; + } +} diff --git a/lib/src/client_proposal_management/domain/client_proposal_model.dart b/lib/src/client_proposal_management/domain/client_proposal_model.dart new file mode 100644 index 0000000..7743a9e --- /dev/null +++ b/lib/src/client_proposal_management/domain/client_proposal_model.dart @@ -0,0 +1,129 @@ +// ClientProposal Model +import 'package:home_front_pk/src/constructor_apply_job/domain/constructor_job.dart'; + +class ClientProposal { + final String id; + final String jobId; + final String constructorId; + final String proposalDescription; + final double proposedCost; + final int estimatedDays; + final List milestones; + final String status; // 'pending', 'accepted', 'rejected' + final DateTime createdAt; + final List attachments; + final ConstructorDetails? constructorDetails; + final String? clientResponse; + final DateTime? respondedAt; + + ClientProposal({ + required this.id, + required this.jobId, + required this.constructorId, + required this.proposalDescription, + required this.proposedCost, + required this.estimatedDays, + required this.milestones, + this.status = 'pending', + required this.createdAt, + this.attachments = const [], + this.constructorDetails, + this.clientResponse, + this.respondedAt, + }); + + factory ClientProposal.fromJson(Map json) { + return ClientProposal( + id: json['id'] ?? '', + jobId: json['jobId'] ?? '', + constructorId: json['constructorId'] ?? '', + proposalDescription: json['proposalDescription'] ?? '', + proposedCost: (json['proposedCost'] ?? 0.0).toDouble(), + estimatedDays: json['estimatedDays'] ?? 0, + milestones: (json['milestones'] as List?) + ?.map((m) => Milestone.fromJson(m)) + .toList() ?? + [], + status: json['status'] ?? 'pending', + createdAt: + DateTime.parse(json['createdAt'] ?? DateTime.now().toIso8601String()), + attachments: List.from(json['attachments'] ?? []), + constructorDetails: json['constructorDetails'] != null + ? ConstructorDetails.fromJson(json['constructorDetails']) + : null, + clientResponse: json['clientResponse'], + respondedAt: json['respondedAt'] != null + ? DateTime.parse(json['respondedAt']) + : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'jobId': jobId, + 'constructorId': constructorId, + 'proposalDescription': proposalDescription, + 'proposedCost': proposedCost, + 'estimatedDays': estimatedDays, + 'milestones': milestones.map((m) => m.toJson()).toList(), + 'status': status, + 'createdAt': createdAt.toIso8601String(), + 'attachments': attachments, + 'clientResponse': clientResponse, + 'respondedAt': respondedAt?.toIso8601String(), + }; + } +} + +class ConstructorDetails { + final String id; + final String name; + final String? profileImage; + final double rating; + final int completedJobs; + final bool isVerified; + final String? phone; + final String email; + final List specializations; + + ConstructorDetails({ + required this.id, + required this.name, + this.profileImage, + required this.rating, + required this.completedJobs, + required this.isVerified, + this.phone, + required this.email, + this.specializations = const [], + }); + + factory ConstructorDetails.fromJson(Map json) { + return ConstructorDetails( + id: json['id'] ?? '', + name: json['name'] ?? '', + profileImage: json['profileImage'], + rating: (json['rating'] ?? 0.0).toDouble(), + completedJobs: json['completedJobs'] ?? 0, + isVerified: json['isVerified'] ?? false, + phone: json['phone'], + email: json['email'] ?? '', + specializations: List.from(json['specializations'] ?? []), + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'profileImage': profileImage, + 'rating': rating, + 'completedJobs': completedJobs, + 'isVerified': isVerified, + 'phone': phone, + 'email': email, + 'specializations': specializations, + }; + } +} diff --git a/lib/src/client_proposal_management/presentation/chat_screen.dart b/lib/src/client_proposal_management/presentation/chat_screen.dart new file mode 100644 index 0000000..40476b3 --- /dev/null +++ b/lib/src/client_proposal_management/presentation/chat_screen.dart @@ -0,0 +1,289 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:home_front_pk/src/client_proposal_management/data/chat_controller.dart'; +import 'package:home_front_pk/src/client_proposal_management/data/client_proposal_controller.dart'; +import 'package:home_front_pk/src/client_proposal_management/domain/chat_message.dart'; +import 'package:home_front_pk/src/client_proposal_management/presentation/image_preview.dart'; +import 'package:home_front_pk/src/features/user_job_post/presentation/user_job_post_controller.dart'; +import 'package:timeago/timeago.dart' as timeago; + +class ChatScreen extends ConsumerStatefulWidget { + final String jobId; + final String constructorId; + + const ChatScreen({ + Key? key, + required this.jobId, + required this.constructorId, + }) : super(key: key); + + @override + ConsumerState createState() => _ChatScreenState(); +} + +class _ChatScreenState extends ConsumerState { + final TextEditingController _messageController = TextEditingController(); + String? _chatId; + + @override + void initState() { + super.initState(); + _initializeChat(); + } + + Future _initializeChat() async { + final repository = ref.read(clientProposalRepositoryProvider); + _chatId = await repository.createChatRoom( + widget.jobId, + widget.constructorId, + ); + setState(() {}); + } + + @override + void dispose() { + _messageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_chatId == null) { + return const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text('Chat'), + ), + body: Column( + children: [ + // Messages List + Expanded( + child: _buildMessagesList(), + ), + + // Message Input + _buildMessageInput(), + ], + ), + ); + } + + Widget _buildMessagesList() { + return Consumer( + builder: (context, ref, child) { + final messagesStream = ref.watch( + chatMessagesProvider(_chatId!), + ); + + return messagesStream.when( + data: (messages) { + if (messages.isEmpty) { + return const Center( + child: Text('No messages yet'), + ); + } + + return ListView.builder( + reverse: true, + padding: const EdgeInsets.all(16), + itemCount: messages.length, + itemBuilder: (context, index) { + final message = messages[index]; + return MessageBubble( + message: message, + isMe: message.senderId == + ref.read(clientProposalRepositoryProvider).currentUserId, + ); + }, + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stack) => Center( + child: Text('Error: $error'), + ), + ); + }, + ); + } + + Widget _buildMessageInput() { + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + border: Border( + top: BorderSide( + color: Theme.of(context).dividerColor, + ), + ), + ), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.attach_file), + onPressed: _pickImage, + ), + Expanded( + child: TextField( + controller: _messageController, + decoration: const InputDecoration( + hintText: 'Type a message', + border: InputBorder.none, + ), + maxLines: null, + ), + ), + IconButton( + icon: const Icon(Icons.send), + onPressed: _sendMessage, + ), + ], + ), + ); + } + + Future _sendMessage() async { + final message = _messageController.text.trim(); + if (message.isEmpty) return; + + try { + await ref.read(clientProposalRepositoryProvider).sendMessage( + _chatId!, + message, + 'text', + ); + _messageController.clear(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _pickImage() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.image, + ); + + if (result != null) { + try { + final file = File(result.files.single.path!); + final url = await ref + .read(chatControllerProvider.notifier) + .uploadChatImage(_chatId!, file); + + // Send message with image URL + await ref.read(chatControllerProvider.notifier).sendMessage( + _chatId!, + url, + 'image', + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + } +} + +class MessageBubble extends StatelessWidget { + final ChatMessage message; + final bool isMe; + + const MessageBubble({ + Key? key, + required this.message, + required this.isMe, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: isMe + ? Theme.of(context).primaryColor + : Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: + isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + if (message.type == 'text') + Text( + message.content, + style: TextStyle( + color: isMe ? Colors.white : null, + ), + ) + else if (message.type == 'image') + GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ImagePreviewScreen( + url: message.content, + ), + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + message.content, + width: 200, + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(height: 4), + Text( + timeago.format(message.timestamp), + style: TextStyle( + fontSize: 12, + color: isMe ? Colors.white70 : Colors.grey, + ), + ), + ], + ), + ), + ); + } +} + +// Add providers +final chatMessagesProvider = + StreamProvider.family, String>((ref, chatId) { + final repository = ref.watch(clientProposalRepositoryProvider); + return repository.getChatMessages(chatId); +}); diff --git a/lib/src/client_proposal_management/presentation/client_job_screen.dart b/lib/src/client_proposal_management/presentation/client_job_screen.dart new file mode 100644 index 0000000..eaee327 --- /dev/null +++ b/lib/src/client_proposal_management/presentation/client_job_screen.dart @@ -0,0 +1,347 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:home_front_pk/src/client_proposal_management/data/client_proposal_controller.dart'; +import 'package:home_front_pk/src/client_proposal_management/data/client_proposal_repo.dart'; +import 'package:home_front_pk/src/client_proposal_management/domain/client_proposal_model.dart'; +import 'package:home_front_pk/src/client_proposal_management/presentation/chat_screen.dart'; +import 'package:home_front_pk/src/client_proposal_management/presentation/client_proposal_screen.dart'; +import 'package:home_front_pk/src/client_proposal_management/presentation/project_detail_screen.dart'; +import 'package:home_front_pk/src/features/user_job_post/domain/job_post_model.dart'; +import 'package:home_front_pk/src/features/user_job_post/presentation/user_job_post_controller.dart'; +import 'package:timeago/timeago.dart' as timeago; + +// Provider for client's jobs +final clientJobsProvider = StreamProvider>((ref) { + final repository = ref.watch(firestoreRepositoryProvider); + return repository.getUserJobs( + ref.watch(firestoreRepositoryProvider).currentUserId!, + ); +}); + +class ClientJobsScreen extends ConsumerWidget { + const ClientJobsScreen({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final jobsAsync = ref.watch(clientJobsProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('My Jobs'), + ), + body: jobsAsync.when( + data: (jobs) { + if (jobs.isEmpty) { + return const Center( + child: Text('No jobs posted yet'), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: jobs.length, + itemBuilder: (context, index) { + return JobCard(job: jobs[index]); + }, + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stack) => Center( + child: Text('Error: $error'), + ), + ), + ); + } +} + +class JobCard extends ConsumerWidget { + final JobPost job; + + const JobCard({ + Key? key, + required this.job, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Watch proposals for this job + final proposalsAsync = ref.watch(jobProposalsProvider(job.id)); + + return Card( + margin: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Job header with image + if (job.images.isNotEmpty) + SizedBox( + height: 200, + width: double.infinity, + child: Stack( + fit: StackFit.expand, + children: [ + Image.network( + job.images.first, + fit: BoxFit.cover, + ), + Positioned( + top: 8, + right: 8, + child: _buildStatusChip(job.status), + ), + ], + ), + ), + + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title and Status + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + job.title, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + if (job.images.isEmpty) _buildStatusChip(job.status), + ], + ), + const SizedBox(height: 8), + + // Location + Row( + children: [ + const Icon(Icons.location_on, size: 16), + const SizedBox(width: 4), + Text(job.location), + ], + ), + const SizedBox(height: 8), + + // Budget and Time + Row( + children: [ + Icon(Icons.account_balance_wallet, + size: 16, color: Theme.of(context).primaryColor), + const SizedBox(width: 4), + Text( + 'Budget: Rs.${job.budget.toStringAsFixed(0)}', + style: TextStyle( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + const Icon(Icons.access_time, size: 16), + const SizedBox(width: 4), + Text( + timeago.format(job.createdAt), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + const SizedBox(height: 16), + + // Proposals count + proposalsAsync.when( + data: (proposals) => Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${proposals.length} Proposals', + style: Theme.of(context).textTheme.titleMedium, + ), + TextButton.icon( + onPressed: () { + // Navigate to proposals screen + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ClientJobProposalsScreen( + jobId: job.id, + jobPost: job, + ), + ), + ); + }, + icon: const Icon(Icons.visibility), + label: const Text('View Proposals'), + ), + ], + ), + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stack) => Text('Error: $error'), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildStatusChip(String status) { + Color color; + String text = status.toUpperCase(); + + switch (status) { + case 'in_progress': + color = Colors.blue; + break; + case 'completed': + color = Colors.green; + break; + case 'cancelled': + color = Colors.red; + break; + default: + color = Colors.orange; + } + + return Chip( + label: Text( + text, + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + backgroundColor: color, + padding: EdgeInsets.zero, + ); + } +} + +// Update the JobActionsBottomSheet +class JobActionsBottomSheet extends ConsumerWidget { + final JobPost job; + final ClientProposal proposal; + + const JobActionsBottomSheet({ + Key? key, + required this.job, + required this.proposal, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + body: Center( + child: Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.chat), + title: const Text('Chat with Constructor'), + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen( + jobId: job.id, + constructorId: proposal.constructorId, + ), + ), + ); + }, + ), + if (job.status == 'in_progress') ...[ + ListTile( + leading: const Icon(Icons.work), + title: const Text('View Project'), + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ProjectDetailsScreen( + jobPost: job, + proposal: proposal, + ), + ), + ); + }, + ), + ListTile( + leading: const Icon(Icons.assignment), + title: const Text('View Milestones'), + onTap: () { + Navigator.pop(context); + // Navigate to milestones screen if you have one + }, + ), + ], + ListTile( + leading: const Icon(Icons.person), + title: const Text('Constructor Profile'), + onTap: () { + Navigator.pop(context); + // Navigate to constructor profile screen if you have one + }, + ), + ], + ), + ), + ), + ); + } + + void showDeleteConfirmationDialog(BuildContext context, WidgetRef ref) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Job'), + content: const Text( + 'Are you sure you want to delete this job? This action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () async { + try { + await ref + .read(clientProposalRepositoryProvider) + .deleteJob(job.id); + if (context.mounted) { + Navigator.pop(context); // Close dialog + Navigator.pop(context); // Close bottom sheet + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Job deleted successfully'), + ), + ); + } + } catch (e) { + if (context.mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error deleting job: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + }, + style: TextButton.styleFrom( + foregroundColor: Colors.red, + ), + child: const Text('Delete'), + ), + ], + ), + ); + } +} diff --git a/lib/src/client_proposal_management/presentation/client_proposal_screen.dart b/lib/src/client_proposal_management/presentation/client_proposal_screen.dart new file mode 100644 index 0000000..cd8d156 --- /dev/null +++ b/lib/src/client_proposal_management/presentation/client_proposal_screen.dart @@ -0,0 +1,498 @@ +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:home_front_pk/src/client_proposal_management/data/client_proposal_controller.dart'; +import 'package:home_front_pk/src/client_proposal_management/domain/client_proposal_model.dart'; +import 'package:home_front_pk/src/client_proposal_management/presentation/client_job_screen.dart'; +import 'package:home_front_pk/src/client_proposal_management/presentation/project_detail_screen.dart'; +import 'package:home_front_pk/src/client_proposal_management/presentation/prosal_detail_screen.dart'; +import 'package:home_front_pk/src/constructor_apply_job/domain/constructor_job.dart'; +import 'package:home_front_pk/src/features/user_job_post/domain/job_post_model.dart'; +import 'package:timeago/timeago.dart' as timeago; + +class ClientJobProposalsScreen extends ConsumerWidget { + final String jobId; + final JobPost jobPost; + + const ClientJobProposalsScreen({ + Key? key, + required this.jobId, + required this.jobPost, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final proposalsAsync = ref.watch(jobProposalsProvider(jobId)); + + return Scaffold( + appBar: AppBar( + title: const Text('Proposals'), + ), + body: Column( + children: [ + // Job Summary Card + JobSummaryCard(jobPost: jobPost), + + // Proposals List + Expanded( + child: proposalsAsync.when( + data: (proposals) { + if (proposals.isEmpty) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.description_outlined, + size: 64, + color: Colors.grey, + ), + SizedBox(height: 16), + Text( + 'No proposals received yet', + style: TextStyle(color: Colors.grey), + ), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: proposals.length, + itemBuilder: (context, index) { + return ProposalCard( + proposal: proposals[index], + jobPost: jobPost, + ); + }, + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stack) => Center( + child: Text('Error: $error'), + ), + ), + ), + ], + ), + ); + } +} + +class JobSummaryCard extends StatelessWidget { + final JobPost jobPost; + + const JobSummaryCard({ + Key? key, + required this.jobPost, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Job Images + if (jobPost.images.isNotEmpty) + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + jobPost.images.first, + width: 80, + height: 80, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 16), + + // Job Details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + jobPost.title, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon(Icons.location_on, size: 16), + const SizedBox(width: 4), + Text(jobPost.location), + ], + ), + const SizedBox(height: 4), + Text( + 'Budget: Rs.${jobPost.budget.toStringAsFixed(0)}', + style: + Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class ProposalCard extends ConsumerWidget { + final ClientProposal proposal; + final JobPost jobPost; + + const ProposalCard({ + Key? key, + required this.proposal, + required this.jobPost, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Safely handle possible null constructor details + final constructor = proposal.constructorDetails; + final isConstructorAvailable = constructor != null; + + return Card( + margin: const EdgeInsets.only(bottom: 16), + child: InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ProposalDetailsScreen( + proposal: proposal, + jobPost: jobPost, + ), + ), + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Constructor Info Section + ListTile( + leading: CircleAvatar( + backgroundImage: + isConstructorAvailable && constructor.profileImage != null + ? NetworkImage(constructor.profileImage!) + : null, + child: + isConstructorAvailable && constructor.profileImage == null + ? Text(constructor.name.isNotEmpty + ? constructor.name[0].toUpperCase() + : '?') + : !isConstructorAvailable + ? const Icon(Icons.person) + : null, + ), + title: Row( + children: [ + Text(isConstructorAvailable + ? constructor.name + : 'Unknown Constructor'), + if (isConstructorAvailable && constructor.isVerified) + const Padding( + padding: EdgeInsets.only(left: 4), + child: Icon( + Icons.verified, + size: 16, + color: Colors.blue, + ), + ), + ], + ), + subtitle: isConstructorAvailable + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.star, + size: 16, + color: Colors.amber, + ), + Text(' ${constructor.rating.toStringAsFixed(1)}'), + Text( + ' • ${constructor.completedJobs} jobs completed'), + ], + ), + ], + ) + : const Text('Constructor details not available'), + trailing: _buildStatusChip(proposal.status), + ), + + // Proposal Summary + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Proposed Amount', + style: Theme.of(context).textTheme.labelLarge, + ), + Text( + 'Rs.${proposal.proposedCost.toStringAsFixed(0)}', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'Duration', + style: Theme.of(context).textTheme.labelLarge, + ), + Text( + '${proposal.estimatedDays} days', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ], + ), + const SizedBox(height: 16), + Text( + proposal.proposalDescription, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Text( + 'Submitted ${timeago.format(proposal.createdAt)}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + + // Action Buttons + if (proposal.status == 'pending') + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => _showRejectDialog(context, ref), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + ), + child: const Text('Reject'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: () => _showAcceptDialog(context, ref), + child: const Text('Accept'), + ), + ), + ], + ), + ), + TextButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => JobActionsBottomSheet( + job: jobPost, proposal: proposal))); + }, + child: Text('View Actions')) + ], + ), + ), + ); + } + + Widget _buildStatusChip(String status) { + Color color; + String text = status.toUpperCase(); + + switch (status) { + case 'accepted': + color = Colors.green; + break; + case 'rejected': + color = Colors.red; + break; + default: + color = Colors.orange; + } + + return Chip( + label: Text( + text, + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + backgroundColor: color, + padding: EdgeInsets.zero, + ); + } + + Future _showRejectDialog(BuildContext context, WidgetRef ref) async { + final reasonController = TextEditingController(); + + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Reject Proposal'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Are you sure you want to reject this proposal?'), + const SizedBox(height: 16), + TextField( + controller: reasonController, + decoration: const InputDecoration( + labelText: 'Reason (Optional)', + hintText: 'Enter reason for rejection', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + style: TextButton.styleFrom(foregroundColor: Colors.red), + onPressed: () => Navigator.pop(context, true), + child: const Text('Reject'), + ), + ], + ), + ); + + if (result == true && context.mounted) { + try { + await ref + .read(clientProposalControllerProvider.notifier) + .rejectProposal( + proposal.id, + reason: reasonController.text, + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Proposal rejected'), + backgroundColor: Colors.red, + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + } + + Future _showAcceptDialog(BuildContext context, WidgetRef ref) async { + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Accept Proposal'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Are you sure you want to accept this proposal? This will:', + ), + const SizedBox(height: 8), + const Text('• Reject all other proposals'), + const Text('• Start the project with this constructor'), + if (proposal.proposedCost > jobPost.budget) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'Note: Proposed cost (Rs.${proposal.proposedCost}) is higher than your budget (Rs.${jobPost.budget})', + style: const TextStyle(color: Colors.orange), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + style: TextButton.styleFrom(foregroundColor: Colors.green), + onPressed: () => Navigator.pop(context, true), + child: const Text('Accept'), + ), + ], + ), + ); + + if (result == true && context.mounted) { + try { + await ref + .read(clientProposalControllerProvider.notifier) + .acceptProposal( + jobPost.id, + proposal.id, + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Proposal accepted'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + } +} diff --git a/lib/src/client_proposal_management/presentation/image_preview.dart b/lib/src/client_proposal_management/presentation/image_preview.dart new file mode 100644 index 0000000..3d441aa --- /dev/null +++ b/lib/src/client_proposal_management/presentation/image_preview.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:photo_view/photo_view.dart'; + +class ImagePreviewScreen extends StatelessWidget { + final String url; + + const ImagePreviewScreen({ + Key? key, + required this.url, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + iconTheme: const IconThemeData(color: Colors.white), + actions: [ + IconButton( + icon: const Icon(Icons.download), + onPressed: () { + // TODO: Implement download functionality if needed + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Download started'), + ), + ); + }, + ), + IconButton( + icon: const Icon(Icons.share), + onPressed: () { + // TODO: Implement share functionality if needed + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Share functionality coming soon'), + ), + ); + }, + ), + ], + ), + body: Stack( + children: [ + // Image with zoom + PhotoView( + imageProvider: NetworkImage(url), + minScale: PhotoViewComputedScale.contained, + maxScale: PhotoViewComputedScale.covered * 2, + initialScale: PhotoViewComputedScale.contained, + backgroundDecoration: const BoxDecoration( + color: Colors.black, + ), + loadingBuilder: (context, event) => Center( + child: CircularProgressIndicator( + value: event == null + ? null + : event.cumulativeBytesLoaded / + (event.expectedTotalBytes ?? 1), + ), + ), + errorBuilder: (context, error, stackTrace) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + color: Colors.white, + size: 32, + ), + const SizedBox(height: 8), + Text( + 'Error loading image', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.white, + ), + ), + ], + ), + ), + ), + + // Bottom gradient for better visibility of close button on white images + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + height: 80, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withOpacity(0.5), + Colors.transparent, + ], + ), + ), + ), + ), + + // Close button for easier navigation + Positioned( + bottom: 16, + right: 16, + child: FloatingActionButton( + mini: true, + backgroundColor: Colors.white.withOpacity(0.3), + onPressed: () => Navigator.of(context).pop(), + child: const Icon(Icons.close, color: Colors.white), + ), + ), + ], + ), + ); + } +} + +// Alternative simple version without photo_view dependency +class SimpleImagePreviewScreen extends StatelessWidget { + final String url; + + const SimpleImagePreviewScreen({ + Key? key, + required this.url, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + iconTheme: const IconThemeData(color: Colors.white), + ), + body: Center( + child: InteractiveViewer( + minScale: 0.5, + maxScale: 4, + child: Image.network( + url, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ); + }, + errorBuilder: (context, error, stackTrace) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + color: Colors.white, + size: 32, + ), + const SizedBox(height: 8), + Text( + 'Error loading image', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.white, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/client_proposal_management/presentation/project_detail_screen.dart b/lib/src/client_proposal_management/presentation/project_detail_screen.dart new file mode 100644 index 0000000..20f7b0c --- /dev/null +++ b/lib/src/client_proposal_management/presentation/project_detail_screen.dart @@ -0,0 +1,528 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:home_front_pk/src/client_proposal_management/data/client_proposal_controller.dart'; +import 'package:home_front_pk/src/client_proposal_management/domain/client_proposal_model.dart'; +import 'package:home_front_pk/src/client_proposal_management/presentation/chat_screen.dart'; +import 'package:home_front_pk/src/constructor_apply_job/domain/constructor_job.dart'; +import 'package:home_front_pk/src/features/user_job_post/domain/job_post_model.dart'; +import 'package:timeago/timeago.dart' as timeago; + +class ProjectDetailsScreen extends ConsumerStatefulWidget { + final JobPost jobPost; + final ClientProposal proposal; + + const ProjectDetailsScreen({ + Key? key, + required this.jobPost, + required this.proposal, + }) : super(key: key); + + @override + ConsumerState createState() => + _ProjectDetailsScreenState(); +} + +class _ProjectDetailsScreenState extends ConsumerState { + int _currentIndex = 0; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Project Details'), + actions: [ + IconButton( + icon: const Icon(Icons.chat), + onPressed: () { + // Navigate to chat screen + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen( + jobId: widget.jobPost.id, + constructorId: widget.proposal.constructorId, + ), + ), + ); + }, + ), + ], + ), + body: Column( + children: [ + // Project Progress Card + ProjectProgressCard( + jobPost: widget.jobPost, + proposal: widget.proposal, + ), + + // Navigation Tabs + TabBar( + onTap: (index) { + setState(() { + _currentIndex = index; + }); + }, + tabs: const [ + Tab(text: 'Milestones'), + Tab(text: 'Details'), + Tab(text: 'Constructor'), + ], + ), + + // Tab Content + Expanded( + child: IndexedStack( + index: _currentIndex, + children: [ + MilestonesTab( + jobPost: widget.jobPost, + proposal: widget.proposal, + ), + DetailsTab( + jobPost: widget.jobPost, + proposal: widget.proposal, + ), + ConstructorTab( + constructor: widget.proposal.constructorDetails!, + ), + ], + ), + ), + ], + ), + ); + } +} + +class ProjectProgressCard extends ConsumerWidget { + final JobPost jobPost; + final ClientProposal proposal; + + const ProjectProgressCard({ + Key? key, + required this.jobPost, + required this.proposal, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final completedMilestones = + proposal.milestones.where((m) => m.status == 'completed').length; + final totalMilestones = proposal.milestones.length; + final progress = + totalMilestones > 0 ? completedMilestones / totalMilestones : 0.0; + + return Card( + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + jobPost.title, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Progress', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: progress, + backgroundColor: Colors.grey[200], + borderRadius: BorderRadius.circular(4), + minHeight: 8, + ), + const SizedBox(height: 4), + Text( + '$completedMilestones of $totalMilestones milestones completed', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'Rs.${proposal.proposedCost}', + style: Theme.of(context).textTheme.titleMedium, + ), + Text( + '${proposal.estimatedDays} days', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ], + ), + ], + ), + ), + ); + } +} + +class MilestonesTab extends ConsumerWidget { + final JobPost jobPost; + final ClientProposal proposal; + + const MilestonesTab({ + Key? key, + required this.jobPost, + required this.proposal, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: proposal.milestones.length, + itemBuilder: (context, index) { + final milestone = proposal.milestones[index]; + return MilestoneCard( + milestone: milestone, + index: index, + onApprove: () => _approveMilestone(context, ref, index), + ); + }, + ); + } + + Future _approveMilestone( + BuildContext context, + WidgetRef ref, + int index, + ) async { + final milestone = proposal.milestones[index]; + + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Approve Milestone'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Are you sure you want to approve this milestone?'), + const SizedBox(height: 16), + Text('Title: ${milestone.title}'), + Text('Amount: Rs.${milestone.amount}'), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Approve'), + ), + ], + ), + ); + + if (result == true && context.mounted) { + try { + // await ref + // .read(clientProposalControllerProvider.notifier) + // .approveMilestone( + // proposal.id, + // index, + // ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Milestone approved'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + } +} + +class DetailsTab extends StatelessWidget { + final JobPost jobPost; + final ClientProposal proposal; + + const DetailsTab({ + Key? key, + required this.jobPost, + required this.proposal, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Project Description', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text(jobPost.description), + const SizedBox(height: 16), + Text( + 'Location', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon(Icons.location_on, size: 16), + const SizedBox(width: 4), + Text(jobPost.location), + ], + ), + if (jobPost.images.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + 'Project Images', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + SizedBox( + height: 120, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: jobPost.images.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + jobPost.images[index], + width: 120, + height: 120, + fit: BoxFit.cover, + ), + ), + ); + }, + ), + ), + ], + ], + ), + ), + ), + ], + ); + } +} + +class ConstructorTab extends StatelessWidget { + final ConstructorDetails constructor; + + const ConstructorTab({ + Key? key, + required this.constructor, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 30, + backgroundImage: constructor.profileImage != null + ? NetworkImage(constructor.profileImage!) + : null, + child: constructor.profileImage == null + ? Text(constructor.name[0].toUpperCase()) + : null, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + constructor.name, + style: Theme.of(context).textTheme.titleLarge, + ), + if (constructor.isVerified) + const Padding( + padding: EdgeInsets.only(left: 4), + child: Icon( + Icons.verified, + size: 20, + color: Colors.blue, + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.star, + size: 16, color: Colors.amber), + Text(' ${constructor.rating.toStringAsFixed(1)}'), + Text( + ' • ${constructor.completedJobs} jobs completed'), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 16), + if (constructor.specializations.isNotEmpty) ...[ + Text( + 'Specializations', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: constructor.specializations + .map((spec) => Chip(label: Text(spec))) + .toList(), + ), + const SizedBox(height: 16), + ], + ListTile( + leading: const Icon(Icons.email), + title: Text(constructor.email), + ), + if (constructor.phone != null) + ListTile( + leading: const Icon(Icons.phone), + title: Text(constructor.phone!), + ), + ], + ), + ), + ), + ], + ); + } +} + +class MilestoneCard extends StatelessWidget { + final Milestone milestone; + final int index; + final VoidCallback onApprove; + + const MilestoneCard({ + Key? key, + required this.milestone, + required this.index, + required this.onApprove, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 16), + child: Column( + children: [ + ListTile( + leading: CircleAvatar( + child: Text('${index + 1}'), + ), + title: Text(milestone.title), + subtitle: Text(milestone.description), + trailing: _buildStatusChip(milestone.status), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Amount', + style: Theme.of(context).textTheme.titleSmall, + ), + Text( + 'Rs.${milestone.amount}', + style: Theme.of(context).textTheme.titleLarge, + ), + ], + ), + if (milestone.status == 'pending') + ElevatedButton( + onPressed: onApprove, + child: const Text('Approve & Pay'), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildStatusChip(String status) { + Color color; + String text = status.toUpperCase(); + + switch (status) { + case 'completed': + color = Colors.green; + break; + case 'in_progress': + color = Colors.blue; + break; + default: + color = Colors.orange; + } + + return Chip( + label: Text( + text, + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + backgroundColor: color, + padding: EdgeInsets.zero, + ); + } +} diff --git a/lib/src/client_proposal_management/presentation/prosal_detail_screen.dart b/lib/src/client_proposal_management/presentation/prosal_detail_screen.dart new file mode 100644 index 0000000..d0cecf9 --- /dev/null +++ b/lib/src/client_proposal_management/presentation/prosal_detail_screen.dart @@ -0,0 +1,504 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:home_front_pk/src/client_proposal_management/data/client_proposal_controller.dart'; +import 'package:home_front_pk/src/client_proposal_management/domain/client_proposal_model.dart'; +import 'package:home_front_pk/src/constructor_apply_job/domain/constructor_job.dart'; +import 'package:home_front_pk/src/features/user_job_post/domain/job_post_model.dart'; + +class ProposalDetailsScreen extends ConsumerWidget { + final ClientProposal proposal; + final JobPost jobPost; + + const ProposalDetailsScreen({ + Key? key, + required this.proposal, + required this.jobPost, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar( + title: const Text('Proposal Details'), + ), + body: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Constructor Profile Card + ConstructorProfileCard(constructor: proposal.constructorDetails!), + + // Proposal Details Card + Card( + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Proposal Details', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + _buildDetailRow( + context, + 'Proposed Amount:', + 'Rs.${proposal.proposedCost.toStringAsFixed(0)}', + Icons.payments, + ), + _buildDetailRow( + context, + 'Duration:', + '${proposal.estimatedDays} days', + Icons.calendar_today, + ), + const SizedBox(height: 16), + Text( + 'Description:', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text(proposal.proposalDescription), + ], + ), + ), + ), + + // Milestones Section + Card( + margin: const EdgeInsets.symmetric(horizontal: 16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Milestones', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: proposal.milestones.length, + itemBuilder: (context, index) { + final milestone = proposal.milestones[index]; + return MilestoneCard( + milestone: milestone, + index: index, + ); + }, + ), + ], + ), + ), + ), + + // Attachments Section + if (proposal.attachments.isNotEmpty) + Card( + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Attachments', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + SizedBox( + height: 100, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: proposal.attachments.length, + itemBuilder: (context, index) { + return AttachmentPreview( + url: proposal.attachments[index], + onTap: () => _showAttachment( + context, + proposal.attachments[index], + ), + ); + }, + ), + ), + ], + ), + ), + ), + + // Action Buttons + if (proposal.status == 'pending') + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => _showRejectDialog(context, ref), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + ), + child: const Text('Reject'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: () => _showAcceptDialog(context, ref), + child: const Text('Accept'), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildDetailRow( + BuildContext context, + String label, + String value, + IconData icon, + ) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Icon(icon, size: 20), + const SizedBox(width: 8), + Text( + label, + style: Theme.of(context).textTheme.titleSmall, + ), + const Spacer(), + Text( + value, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } + + Future _showRejectDialog(BuildContext context, WidgetRef ref) async { + final reasonController = TextEditingController(); + final formKey = GlobalKey(); + + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Reject Proposal'), + content: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Are you sure you want to reject this proposal? This action cannot be undone.', + ), + const SizedBox(height: 16), + TextFormField( + controller: reasonController, + decoration: const InputDecoration( + labelText: 'Reason for rejection', + hintText: 'Optional feedback for the constructor', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Reject'), + ), + ], + ), + ); + + if (result == true && context.mounted) { + try { + await ref + .read(clientProposalControllerProvider.notifier) + .rejectProposal(proposal.id, reason: reasonController.text); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Proposal rejected'), + backgroundColor: Colors.red, + ), + ); + Navigator.pop(context); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + } + + Future _showAcceptDialog(BuildContext context, WidgetRef ref) async { + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Accept Proposal'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Are you sure you want to accept this proposal? This will:', + ), + const SizedBox(height: 16), + Text('• Reject all other proposals'), + Text('• Start the project with this constructor'), + Text('• Set up milestones for payment'), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + child: const Text('Accept'), + ), + ], + ), + ); + + if (result == true && context.mounted) { + try { + await ref + .read(clientProposalControllerProvider.notifier) + .acceptProposal(jobPost.id, proposal.id); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Proposal accepted'), + backgroundColor: Colors.green, + ), + ); + Navigator.pop(context); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + } + + void _showAttachment(BuildContext context, String url) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AttachmentViewScreen(url: url), + ), + ); + } +} + +// Additional widget implementations +class ConstructorProfileCard extends StatelessWidget { + final ConstructorDetails constructor; + + const ConstructorProfileCard({ + Key? key, + required this.constructor, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + ListTile( + leading: CircleAvatar( + radius: 30, + backgroundImage: constructor.profileImage != null + ? NetworkImage(constructor.profileImage!) + : null, + child: constructor.profileImage == null + ? Text( + constructor.name[0].toUpperCase(), + style: const TextStyle(fontSize: 24), + ) + : null, + ), + title: Row( + children: [ + Text( + constructor.name, + style: Theme.of(context).textTheme.titleLarge, + ), + if (constructor.isVerified) + const Padding( + padding: EdgeInsets.only(left: 4), + child: Icon( + Icons.verified, + size: 20, + color: Colors.blue, + ), + ), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.star, size: 16, color: Colors.amber), + Text(' ${constructor.rating.toStringAsFixed(1)}'), + Text(' • ${constructor.completedJobs} jobs completed'), + ], + ), + ], + ), + ), + if (constructor.specializations.isNotEmpty) ...[ + const Divider(), + Wrap( + spacing: 8, + children: constructor.specializations + .map((spec) => Chip(label: Text(spec))) + .toList(), + ), + ], + ], + ), + ), + ); + } +} + +class MilestoneCard extends StatelessWidget { + final Milestone milestone; + final int index; + + const MilestoneCard({ + Key? key, + required this.milestone, + required this.index, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: CircleAvatar( + child: Text('${index + 1}'), + ), + title: Text(milestone.title), + subtitle: Text(milestone.description), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'Rs.${milestone.amount.toStringAsFixed(0)}', + style: Theme.of(context).textTheme.titleMedium, + ), + Text( + '${milestone.daysToComplete} days', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ); + } +} + +class AttachmentPreview extends StatelessWidget { + final String url; + final VoidCallback onTap; + + const AttachmentPreview({ + Key? key, + required this.url, + required this.onTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 100, + height: 100, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + image: DecorationImage( + image: NetworkImage(url), + fit: BoxFit.cover, + ), + ), + ), + ); + } +} + +class AttachmentViewScreen extends StatelessWidget { + final String url; + + const AttachmentViewScreen({ + Key? key, + required this.url, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(), + body: Center( + child: InteractiveViewer( + child: Image.network(url), + ), + ), + ); + } +} diff --git a/lib/src/constructor_apply_job/data/job_proposal_provider.dart b/lib/src/constructor_apply_job/data/job_proposal_provider.dart new file mode 100644 index 0000000..81cd65a --- /dev/null +++ b/lib/src/constructor_apply_job/data/job_proposal_provider.dart @@ -0,0 +1,99 @@ +// Providers +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:home_front_pk/src/constructor_apply_job/data/job_proposal_repo.dart'; +import 'package:home_front_pk/src/constructor_apply_job/domain/constructor_job.dart'; +import 'package:home_front_pk/src/features/user_job_post/data/image_upload_repo.dart'; +import 'package:home_front_pk/src/features/user_job_post/presentation/user_job_post_controller.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +final jobProposalRepositoryProvider = Provider((ref) { + final storageRepo = ref.watch(storageRepositoryProvider); + return JobProposalRepository(storageRepository: storageRepo); +}); + +final constructorProposalsProvider = + StreamProvider.family, String>((ref, constructorId) { + final repository = ref.watch(jobProposalRepositoryProvider); + return repository.getConstructorProposals(constructorId); +}); + +final jobProposalsProvider = + StreamProvider.family, String>((ref, jobId) { + final repository = ref.watch(jobProposalRepositoryProvider); + return repository.getJobProposals(jobId); +}); + +// Controller +class ProposalController extends StateNotifier> { + final JobProposalRepository _repository; + final StorageRepository _storageRepository; + + ProposalController({ + required JobProposalRepository repository, + required StorageRepository storageRepository, + }) : _repository = repository, + _storageRepository = storageRepository, + super(const AsyncValue.data(null)); + + Future submitProposal({ + required String jobId, + required String description, + required double proposedCost, + required int estimatedDays, + required List milestones, + required List attachments, + }) async { + state = const AsyncValue.loading(); + try { + final proposal = JobProposal( + id: '', // Will be set in repository + jobId: jobId, + constructorId: '', // Will be set in repository + proposalDescription: description, + proposedCost: proposedCost, + estimatedDays: estimatedDays, + milestones: milestones, + createdAt: DateTime.now(), + ); + + await _repository.submitProposal(proposal, attachments); + state = const AsyncValue.data(null); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future updateProposalStatus(String proposalId, String status) async { + state = const AsyncValue.loading(); + try { + await _repository.updateProposalStatus(proposalId, status); + state = const AsyncValue.data(null); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future updateMilestoneStatus( + String proposalId, int milestoneIndex, String status) async { + state = const AsyncValue.loading(); + try { + await _repository.updateMilestoneStatus( + proposalId, milestoneIndex, status); + state = const AsyncValue.data(null); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } +} + +final proposalControllerProvider = + StateNotifierProvider>((ref) { + final repository = ref.watch(jobProposalRepositoryProvider); + final storageRepository = ref.watch(storageRepositoryProvider); + return ProposalController( + repository: repository, + storageRepository: storageRepository, + ); +}); diff --git a/lib/src/constructor_apply_job/data/job_proposal_repo.dart b/lib/src/constructor_apply_job/data/job_proposal_repo.dart new file mode 100644 index 0000000..3f623ff --- /dev/null +++ b/lib/src/constructor_apply_job/data/job_proposal_repo.dart @@ -0,0 +1,123 @@ +import 'dart:io'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:home_front_pk/src/constructor_apply_job/domain/constructor_job.dart'; +import 'package:home_front_pk/src/features/user_job_post/data/image_upload_repo.dart'; + +class JobProposalRepository { + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + final FirebaseAuth _auth = FirebaseAuth.instance; + final StorageRepository _storageRepository; + + JobProposalRepository({required StorageRepository storageRepository}) + : _storageRepository = storageRepository; + + String? get currentUserId => _auth.currentUser?.uid; + + Future submitProposal( + JobProposal proposal, List attachments) async { + try { + if (currentUserId == null) { + throw Exception('No user logged in'); + } + + // Upload attachments if any + List attachmentUrls = []; + if (attachments.isNotEmpty) { + attachmentUrls = await _storageRepository.uploadJobImages( + attachments, + 'proposals/${proposal.jobId}', + ); + } + + // Create a new document reference + final docRef = _firestore.collection('proposals').doc(); + + // Update the proposal with the document ID and attachments + final updatedProposal = proposal.copyWith( + id: docRef.id, + constructorId: currentUserId, + attachments: attachmentUrls, + ); + + // Set the document data + await docRef.set(updatedProposal.toJson()); + } catch (e) { + throw Exception('Failed to submit proposal: $e'); + } + } + + Stream> getConstructorProposals(String constructorId) { + return _firestore + .collection('proposals') + .where('constructorId', isEqualTo: constructorId) + .orderBy('createdAt', descending: true) + .snapshots() + .map((snapshot) => snapshot.docs + .map((doc) => JobProposal.fromJson({ + ...doc.data(), + 'id': doc.id, + })) + .toList()); + } + + Stream> getJobProposals(String jobId) { + return _firestore + .collection('proposals') + .where('jobId', isEqualTo: jobId) + .orderBy('createdAt', descending: true) + .snapshots() + .map((snapshot) => snapshot.docs + .map((doc) => JobProposal.fromJson({ + ...doc.data(), + 'id': doc.id, + })) + .toList()); + } + + Future updateProposalStatus(String proposalId, String status) async { + try { + await _firestore.collection('proposals').doc(proposalId).update({ + 'status': status, + }); + } catch (e) { + throw Exception('Failed to update proposal status: $e'); + } + } + + Future updateMilestoneStatus( + String proposalId, int milestoneIndex, String status) async { + try { + DocumentSnapshot doc = + await _firestore.collection('proposals').doc(proposalId).get(); + + if (!doc.exists) { + throw Exception('Proposal not found'); + } + + JobProposal proposal = JobProposal.fromJson({ + ...doc.data() as Map, + 'id': doc.id, + }); + + List updatedMilestones = [...proposal.milestones]; + if (milestoneIndex < updatedMilestones.length) { + Milestone milestone = updatedMilestones[milestoneIndex]; + updatedMilestones[milestoneIndex] = Milestone( + title: milestone.title, + description: milestone.description, + amount: milestone.amount, + daysToComplete: milestone.daysToComplete, + status: status, + ); + + await doc.reference.update({ + 'milestones': updatedMilestones.map((m) => m.toJson()).toList(), + }); + } + } catch (e) { + throw Exception('Failed to update milestone status: $e'); + } + } +} diff --git a/lib/src/constructor_apply_job/domain/constructor_job.dart b/lib/src/constructor_apply_job/domain/constructor_job.dart new file mode 100644 index 0000000..e4f7890 --- /dev/null +++ b/lib/src/constructor_apply_job/domain/constructor_job.dart @@ -0,0 +1,122 @@ +class JobProposal { + final String id; + final String jobId; + final String constructorId; + final String proposalDescription; + final double proposedCost; + final int estimatedDays; + final List milestones; + final String status; // 'pending', 'accepted', 'rejected' + final DateTime createdAt; + final List attachments; // For any supporting documents + + JobProposal({ + required this.id, + required this.jobId, + required this.constructorId, + required this.proposalDescription, + required this.proposedCost, + required this.estimatedDays, + required this.milestones, + this.status = 'pending', + required this.createdAt, + this.attachments = const [], + }); + + Map toJson() { + return { + 'id': id, + 'jobId': jobId, + 'constructorId': constructorId, + 'proposalDescription': proposalDescription, + 'proposedCost': proposedCost, + 'estimatedDays': estimatedDays, + 'milestones': milestones.map((m) => m.toJson()).toList(), + 'status': status, + 'createdAt': createdAt.toIso8601String(), + 'attachments': attachments, + }; + } + + factory JobProposal.fromJson(Map json) { + return JobProposal( + id: json['id'] ?? '', + jobId: json['jobId'] ?? '', + constructorId: json['constructorId'] ?? '', + proposalDescription: json['proposalDescription'] ?? '', + proposedCost: (json['proposedCost'] ?? 0).toDouble(), + estimatedDays: json['estimatedDays'] ?? 0, + milestones: (json['milestones'] as List?) + ?.map((m) => Milestone.fromJson(m)) + .toList() ?? + [], + status: json['status'] ?? 'pending', + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt']) + : DateTime.now(), + attachments: List.from(json['attachments'] ?? []), + ); + } + + JobProposal copyWith({ + String? id, + String? jobId, + String? constructorId, + String? proposalDescription, + double? proposedCost, + int? estimatedDays, + List? milestones, + String? status, + DateTime? createdAt, + List? attachments, + }) { + return JobProposal( + id: id ?? this.id, + jobId: jobId ?? this.jobId, + constructorId: constructorId ?? this.constructorId, + proposalDescription: proposalDescription ?? this.proposalDescription, + proposedCost: proposedCost ?? this.proposedCost, + estimatedDays: estimatedDays ?? this.estimatedDays, + milestones: milestones ?? this.milestones, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + attachments: attachments ?? this.attachments, + ); + } +} + +class Milestone { + final String title; + final String description; + final double amount; + final int daysToComplete; + final String status; // 'pending', 'in_progress', 'completed' + + Milestone({ + required this.title, + required this.description, + required this.amount, + required this.daysToComplete, + this.status = 'pending', + }); + + Map toJson() { + return { + 'title': title, + 'description': description, + 'amount': amount, + 'daysToComplete': daysToComplete, + 'status': status, + }; + } + + factory Milestone.fromJson(Map json) { + return Milestone( + title: json['title'] ?? '', + description: json['description'] ?? '', + amount: (json['amount'] ?? 0).toDouble(), + daysToComplete: json['daysToComplete'] ?? 0, + status: json['status'] ?? 'pending', + ); + } +} diff --git a/lib/src/constructor_apply_job/presentation/constructor_job_screen.dart b/lib/src/constructor_apply_job/presentation/constructor_job_screen.dart new file mode 100644 index 0000000..092c3c4 --- /dev/null +++ b/lib/src/constructor_apply_job/presentation/constructor_job_screen.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:home_front_pk/src/constructor_apply_job/presentation/submit_proposal_screen.dart'; +import 'package:home_front_pk/src/features/user_job_post/domain/job_post_model.dart'; +import 'package:home_front_pk/src/features/user_job_post/presentation/user_job_post_controller.dart'; +import 'package:timeago/timeago.dart' as timeago; + +// Provider to filter jobs by category and status +final availableJobsProvider = StreamProvider.autoDispose>((ref) { + final repository = ref.watch(firestoreRepositoryProvider); + // Get jobs specifically for constructors that are still pending + return repository.getJobPosts('constructor').map( + (jobs) => jobs.where((job) => job.status == 'pending').toList(), + ); +}); + +class ConstructorJobsScreen extends ConsumerWidget { + const ConstructorJobsScreen({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final jobsAsync = ref.watch(availableJobsProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Available Jobs'), + ), + body: jobsAsync.when( + data: (jobs) => jobs.isEmpty + ? const Center( + child: Text('No jobs available at the moment'), + ) + : ListView.builder( + itemCount: jobs.length, + padding: const EdgeInsets.all(16), + itemBuilder: (context, index) { + final job = jobs[index]; + return JobCard(job: job); + }, + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center( + child: Text('Error: ${error.toString()}'), + ), + ), + ); + } +} + +class JobCard extends StatelessWidget { + final JobPost job; + + const JobCard({Key? key, required this.job}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 16), + child: InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SubmitProposalScreen(jobPost: job), + ), + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Job Images + if (job.images.isNotEmpty) + SizedBox( + height: 200, + child: PageView.builder( + itemCount: job.images.length, + itemBuilder: (context, index) { + return Image.network( + job.images[index], + fit: BoxFit.cover, + ); + }, + ), + ), + + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title and Budget + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + job.title, + style: Theme.of(context).textTheme.titleLarge, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + 'Budget: Rs.${job.budget.toStringAsFixed(0)}', + style: + Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + const SizedBox(height: 8), + + // Location + Row( + children: [ + const Icon(Icons.location_on, size: 16), + const SizedBox(width: 4), + Text( + job.location, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + const SizedBox(height: 8), + + // Description + Text( + job.description, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + + // Tags + if (job.tags.isNotEmpty) + Wrap( + spacing: 8, + children: job.tags.map((tag) { + return Chip( + label: Text(tag), + padding: const EdgeInsets.all(4), + ); + }).toList(), + ), + + // Posted Time and Apply Button + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Posted ${timeago.format(job.createdAt)}', + style: Theme.of(context).textTheme.bodySmall, + ), + ElevatedButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + SubmitProposalScreen(jobPost: job), + ), + ); + }, + child: const Text('Apply Now'), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/constructor_apply_job/presentation/submit_proposal_screen.dart b/lib/src/constructor_apply_job/presentation/submit_proposal_screen.dart new file mode 100644 index 0000000..baf8240 --- /dev/null +++ b/lib/src/constructor_apply_job/presentation/submit_proposal_screen.dart @@ -0,0 +1,467 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:home_front_pk/src/constructor_apply_job/data/job_proposal_provider.dart'; +import 'package:home_front_pk/src/constructor_apply_job/domain/constructor_job.dart'; +import 'package:home_front_pk/src/features/user_job_post/domain/job_post_model.dart'; + +class SubmitProposalScreen extends ConsumerStatefulWidget { + final JobPost jobPost; + + const SubmitProposalScreen({Key? key, required this.jobPost}) + : super(key: key); + + @override + ConsumerState createState() => + _SubmitProposalScreenState(); +} + +class _SubmitProposalScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _descriptionController = TextEditingController(); + final _costController = TextEditingController(); + final _daysController = TextEditingController(); + List _milestones = []; + List _attachments = []; + + @override + void dispose() { + _descriptionController.dispose(); + _costController.dispose(); + _daysController.dispose(); + super.dispose(); + } + + Future _pickAttachments() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.image, + allowMultiple: true, + ); + + if (result != null) { + setState(() { + _attachments.addAll( + result.files.map((file) => File(file.path!)).toList(), + ); + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Submit Proposal'), + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // ... (previous widgets from part 1) + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Job Details', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text('Title: ${widget.jobPost.title}'), + Text( + 'Budget: Rs.${widget.jobPost.budget.toStringAsFixed(0)}'), + Text('Location: ${widget.jobPost.location}'), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Proposal Description + TextFormField( + controller: _descriptionController, + maxLines: 5, + decoration: const InputDecoration( + labelText: 'Proposal Description', + hintText: 'Describe how you plan to execute this project...', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a description'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Cost and Duration + Row( + children: [ + Expanded( + child: TextFormField( + controller: _costController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Proposed Cost (Rs.)', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Required'; + } + if (double.tryParse(value) == null) { + return 'Invalid number'; + } + return null; + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _daysController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Days to Complete', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Required'; + } + if (int.tryParse(value) == null) { + return 'Invalid number'; + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 24), + // Milestones Section + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Milestones', + style: Theme.of(context).textTheme.titleLarge, + ), + ElevatedButton.icon( + onPressed: () => _showMilestoneDialog(context), + icon: const Icon(Icons.add), + label: const Text('Add'), + ), + ], + ), + if (_milestones.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Text('No milestones added yet'), + ), + ..._milestones.asMap().entries.map((entry) { + final index = entry.key; + final milestone = entry.value; + return Card( + margin: const EdgeInsets.symmetric(vertical: 8), + child: ListTile( + title: Text(milestone.title), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(milestone.description), + Text( + 'Amount: Rs.${milestone.amount.toStringAsFixed(0)} - ${milestone.daysToComplete} days', + style: const TextStyle( + fontWeight: FontWeight.bold), + ), + ], + ), + trailing: IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + setState(() { + _milestones.removeAt(index); + }); + }, + ), + ), + ); + }), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Attachments Section + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Attachments', + style: Theme.of(context).textTheme.titleLarge, + ), + ElevatedButton.icon( + onPressed: _pickAttachments, + icon: const Icon(Icons.attach_file), + label: const Text('Add'), + ), + ], + ), + const SizedBox(height: 16), + if (_attachments.isEmpty) + const Text('No attachments added yet') + else + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _attachments.length, + itemBuilder: (context, index) { + final file = _attachments[index]; + return ListTile( + leading: const Icon(Icons.insert_drive_file), + title: Text(file.path.split('/').last), + trailing: IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + setState(() { + _attachments.removeAt(index); + }); + }, + ), + ); + }, + ), + ], + ), + ), + ), + const SizedBox(height: 24), + + // Submit Button + Consumer( + builder: (context, ref, child) { + final proposalState = ref.watch(proposalControllerProvider); + + return proposalState.when( + data: (_) => ElevatedButton( + onPressed: _submitProposal, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: const Text('Submit Proposal'), + ), + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, _) => Column( + children: [ + Text( + 'Error: $error', + style: const TextStyle(color: Colors.red), + ), + ElevatedButton( + onPressed: _submitProposal, + child: const Text('Retry'), + ), + ], + ), + ); + }, + ), + ], + ), + ), + ); + } + + Future _showMilestoneDialog(BuildContext context) async { + final result = await showDialog( + context: context, + builder: (context) => const MilestoneDialog(), + ); + + if (result != null) { + setState(() { + _milestones.add(result); + }); + } + } + + Future _submitProposal() async { + if (!_formKey.currentState!.validate()) { + return; + } + + if (_milestones.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please add at least one milestone'), + backgroundColor: Colors.red, + ), + ); + return; + } + + try { + await ref.read(proposalControllerProvider.notifier).submitProposal( + jobId: widget.jobPost.id, + description: _descriptionController.text, + proposedCost: double.parse(_costController.text), + estimatedDays: int.parse(_daysController.text), + milestones: _milestones, + attachments: _attachments, + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Proposal submitted successfully'), + backgroundColor: Colors.green, + ), + ); + Navigator.pop(context); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } + } +} + +// Add this as a separate class in the same file or a new file +class MilestoneDialog extends StatefulWidget { + const MilestoneDialog({Key? key}) : super(key: key); + + @override + State createState() => _MilestoneDialogState(); +} + +class _MilestoneDialogState extends State { + final _formKey = GlobalKey(); + final _titleController = TextEditingController(); + final _descriptionController = TextEditingController(); + final _amountController = TextEditingController(); + final _daysController = TextEditingController(); + + @override + void dispose() { + _titleController.dispose(); + _descriptionController.dispose(); + _amountController.dispose(); + _daysController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Add Milestone'), + content: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _titleController, + decoration: const InputDecoration( + labelText: 'Title', + border: OutlineInputBorder(), + ), + validator: (value) => + value?.isEmpty ?? true ? 'Required' : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _descriptionController, + maxLines: 3, + decoration: const InputDecoration( + labelText: 'Description', + border: OutlineInputBorder(), + ), + validator: (value) => + value?.isEmpty ?? true ? 'Required' : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _amountController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Amount (Rs.)', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value?.isEmpty ?? true) return 'Required'; + if (double.tryParse(value!) == null) return 'Invalid number'; + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _daysController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Days to Complete', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value?.isEmpty ?? true) return 'Required'; + if (int.tryParse(value!) == null) return 'Invalid number'; + return null; + }, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + Navigator.pop( + context, + Milestone( + title: _titleController.text, + description: _descriptionController.text, + amount: double.parse(_amountController.text), + daysToComplete: int.parse(_daysController.text), + ), + ); + } + }, + child: const Text('Add'), + ), + ], + ); + } +} diff --git a/lib/src/features/dashboard/presentation/client_dashboard/client_dashboard.dart b/lib/src/features/dashboard/presentation/client_dashboard/client_dashboard.dart index dc7ecf0..35228ab 100644 --- a/lib/src/features/dashboard/presentation/client_dashboard/client_dashboard.dart +++ b/lib/src/features/dashboard/presentation/client_dashboard/client_dashboard.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:home_front_pk/src/client_proposal_management/presentation/client_job_screen.dart'; +import 'package:home_front_pk/src/client_proposal_management/presentation/client_proposal_screen.dart'; import 'package:home_front_pk/src/common_widgets/alert_dialogs.dart'; import 'package:home_front_pk/src/common_widgets/async_value_widget.dart'; import 'package:home_front_pk/src/common_widgets/home_app_bar.dart'; @@ -95,6 +97,14 @@ class _ClientDashboardState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + TextButton( + onPressed: () { + Navigator.push(context, + MaterialPageRoute(builder: (context) { + return ClientJobsScreen(); + })); + }, + child: Text('proposal')), Padding( padding: EdgeInsets.only(top: 20, left: 5, right: 5), child: Expanded( diff --git a/lib/src/features/dashboard/presentation/constructor_dashboard/constrcutor_dashboard.dart b/lib/src/features/dashboard/presentation/constructor_dashboard/constrcutor_dashboard.dart index 09f4a8c..4c3a6ca 100644 --- a/lib/src/features/dashboard/presentation/constructor_dashboard/constrcutor_dashboard.dart +++ b/lib/src/features/dashboard/presentation/constructor_dashboard/constrcutor_dashboard.dart @@ -7,6 +7,7 @@ import 'package:home_front_pk/src/common_widgets/cutome_curved_container.dart'; import 'package:home_front_pk/src/common_widgets/grid_card.dart'; import 'package:home_front_pk/src/common_widgets/home_app_bar.dart'; import 'package:home_front_pk/src/constants/app_sizes.dart'; +import 'package:home_front_pk/src/constructor_apply_job/presentation/constructor_job_screen.dart'; import 'package:home_front_pk/src/features/authentication/presentation/account/account_screen_controller.dart'; import 'package:home_front_pk/src/features/chat_section/presentation/chat_screen.dart'; import 'package:home_front_pk/src/localization/string_hardcoded.dart'; @@ -81,7 +82,11 @@ class _ConstructorDashboardState extends ConsumerState { gapH8, ElevatedButton( onPressed: () { - showNotImplementedAlertDialog(context: context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + ConstructorJobsScreen())); }, child: const Text('Find Jobs')), ], diff --git a/lib/src/features/user_job_post/data/image_upload_repo.dart b/lib/src/features/user_job_post/data/image_upload_repo.dart index e891045..5035c01 100644 --- a/lib/src/features/user_job_post/data/image_upload_repo.dart +++ b/lib/src/features/user_job_post/data/image_upload_repo.dart @@ -22,4 +22,12 @@ class StorageRepository { throw Exception('Failed to upload images: $e'); } } + + Future uploadChatImage(String chatId, File image) async { + final ref = _storage + .ref() + .child('chats/$chatId/${DateTime.now().millisecondsSinceEpoch}'); + await ref.putFile(image); + return await ref.getDownloadURL(); + } } diff --git a/pubspec.lock b/pubspec.lock index 124290d..1ad6da2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -337,6 +337,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "825aec673606875c33cd8d3c4083f1a3c3999015a84178b317b7ef396b7384f3" + url: "https://pub.dev" + source: hosted + version: "8.0.7" file_selector_linux: dependency: transitive description: @@ -951,6 +959,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.2" + photo_view: + dependency: "direct main" + description: + name: photo_view + sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e" + url: "https://pub.dev" + source: hosted + version: "0.15.0" platform: dependency: transitive description: @@ -1204,6 +1220,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.0" + timeago: + dependency: "direct main" + description: + name: timeago + sha256: "054cedf68706bb142839ba0ae6b135f6b68039f0b8301cbe8784ae653d5ff8de" + url: "https://pub.dev" + source: hosted + version: "3.7.0" timing: dependency: transitive description: @@ -1284,6 +1308,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: "68d1e89a91ed61ad9c370f9f8b6effed9ae5e0ede22a270bdfa6daf79fc2290a" + url: "https://pub.dev" + source: hosted + version: "5.5.4" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 02e9861..8211d87 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,6 +60,9 @@ dependencies: flutter_staggered_grid_view: 0.7.0 fl_chart: ^0.68.0 image_picker: ^1.1.2 + timeago: ^3.7.0 + file_picker: ^8.0.7 + photo_view: ^0.15.0 dev_dependencies: