Checkout Plan
SmartCrew Admin - Checkout Plan
Equipment Checkout Feature - Mobile Implementation Plan
Overview
The Equipment Checkout feature enables mobile app users to check out and check in equipment for their work shifts. This document provides comprehensive guidance for implementing this feature on the mobile side, integrating with the existing SmartCrew admin system.
Feature Purpose
- Equipment Tracking: Track which equipment is being used by which employee
- Accountability: Establish clear responsibility chains for equipment usage
- Maintenance Scheduling: Enable better equipment maintenance planning based on usage patterns
- Cost Allocation: Track equipment costs per project for accurate billing
- Safety Compliance: Ensure proper equipment assignment and usage documentation
System Architecture
Admin Side (Existing)
- Equipment management through web console
- Equipment categories and maintenance records
- Usage reporting and analytics
- Equipment availability tracking
Mobile Side (To Be Implemented)
- Equipment checkout/checkin interface
- Barcode/QR code scanning for equipment identification
- Offline capability for remote job sites
- Real-time sync with admin system
Data Models
Core Equipment Model (Existing)
class Equipment {
final String firebaseUid;
final String? companyId;
final String name;
final String? type;
final String? categoryId;
final int year;
final double price;
final double hourRate;
final Timestamp? purchaseDate;
final bool isActive;
}New Models Required for Mobile
EquipmentCheckout Model
class EquipmentCheckout {
final String? id;
final String equipmentId;
final String equipmentName;
final String userId;
final String userName;
final String? projectId;
final String? projectName;
final DateTime checkoutTime;
final DateTime? checkinTime;
final CheckoutStatus status;
final String? checkoutNotes;
final String? checkinNotes;
final List<String>? checkoutPhotos;
final List<String>? checkinPhotos;
final String? companyId;
final String? location;
final EquipmentCondition? checkoutCondition;
final EquipmentCondition? checkinCondition;
const EquipmentCheckout({
this.id,
required this.equipmentId,
required this.equipmentName,
required this.userId,
required this.userName,
this.projectId,
this.projectName,
required this.checkoutTime,
this.checkinTime,
required this.status,
this.checkoutNotes,
this.checkinNotes,
this.checkoutPhotos,
this.checkinPhotos,
this.companyId,
this.location,
this.checkoutCondition,
this.checkinCondition,
});
factory EquipmentCheckout.fromJson(Map<String, dynamic> json) {
return EquipmentCheckout(
id: json['id']?.toString(),
equipmentId: json['equipmentId']?.toString() ?? '',
equipmentName: json['equipmentName']?.toString() ?? '',
userId: json['userId']?.toString() ?? '',
userName: json['userName']?.toString() ?? '',
projectId: json['projectId']?.toString(),
projectName: json['projectName']?.toString(),
checkoutTime: _parseDateTime(json['checkoutTime']) ?? DateTime.now(),
checkinTime: _parseDateTime(json['checkinTime']),
status: CheckoutStatus.values.firstWhere(
(s) => s.name == json['status'],
orElse: () => CheckoutStatus.checkedOut,
),
checkoutNotes: json['checkoutNotes']?.toString(),
checkinNotes: json['checkinNotes']?.toString(),
checkoutPhotos: (json['checkoutPhotos'] as List?)?.cast<String>(),
checkinPhotos: (json['checkinPhotos'] as List?)?.cast<String>(),
companyId: json['companyId']?.toString(),
location: json['location']?.toString(),
checkoutCondition: json['checkoutCondition'] != null
? EquipmentCondition.values.firstWhere(
(c) => c.name == json['checkoutCondition'],
orElse: () => EquipmentCondition.good,
)
: null,
checkinCondition: json['checkinCondition'] != null
? EquipmentCondition.values.firstWhere(
(c) => c.name == json['checkinCondition'],
orElse: () => EquipmentCondition.good,
)
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'equipmentId': equipmentId,
'equipmentName': equipmentName,
'userId': userId,
'userName': userName,
'projectId': projectId,
'projectName': projectName,
'checkoutTime': checkoutTime.millisecondsSinceEpoch,
'checkinTime': checkinTime?.millisecondsSinceEpoch,
'status': status.name,
'checkoutNotes': checkoutNotes,
'checkinNotes': checkinNotes,
'checkoutPhotos': checkoutPhotos,
'checkinPhotos': checkinPhotos,
'companyId': companyId,
'location': location,
'checkoutCondition': checkoutCondition?.name,
'checkinCondition': checkinCondition?.name,
};
}
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
if (value is DateTime) return value;
if (value is Timestamp) return value.toDate();
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value);
if (value is double) return DateTime.fromMillisecondsSinceEpoch(value.toInt());
return null;
}
EquipmentCheckout copyWith({
String? id,
String? equipmentId,
String? equipmentName,
String? userId,
String? userName,
String? projectId,
String? projectName,
DateTime? checkoutTime,
DateTime? checkinTime,
CheckoutStatus? status,
String? checkoutNotes,
String? checkinNotes,
List<String>? checkoutPhotos,
List<String>? checkinPhotos,
String? companyId,
String? location,
EquipmentCondition? checkoutCondition,
EquipmentCondition? checkinCondition,
}) {
return EquipmentCheckout(
id: id ?? this.id,
equipmentId: equipmentId ?? this.equipmentId,
equipmentName: equipmentName ?? this.equipmentName,
userId: userId ?? this.userId,
userName: userName ?? this.userName,
projectId: projectId ?? this.projectId,
projectName: projectName ?? this.projectName,
checkoutTime: checkoutTime ?? this.checkoutTime,
checkinTime: checkinTime ?? this.checkinTime,
status: status ?? this.status,
checkoutNotes: checkoutNotes ?? this.checkoutNotes,
checkinNotes: checkinNotes ?? this.checkinNotes,
checkoutPhotos: checkoutPhotos ?? this.checkoutPhotos,
checkinPhotos: checkinPhotos ?? this.checkinPhotos,
companyId: companyId ?? this.companyId,
location: location ?? this.location,
checkoutCondition: checkoutCondition ?? this.checkoutCondition,
checkinCondition: checkinCondition ?? this.checkinCondition,
);
}
}Supporting Enums
enum CheckoutStatus {
checkedOut,
checkedIn,
overdue,
lost,
maintenance;
String get displayName {
switch (this) {
case CheckoutStatus.checkedOut:
return 'Checked Out';
case CheckoutStatus.checkedIn:
return 'Checked In';
case CheckoutStatus.overdue:
return 'Overdue';
case CheckoutStatus.lost:
return 'Lost/Missing';
case CheckoutStatus.maintenance:
return 'In Maintenance';
}
}
Color get color {
switch (this) {
case CheckoutStatus.checkedOut:
return Colors.blue;
case CheckoutStatus.checkedIn:
return Colors.green;
case CheckoutStatus.overdue:
return Colors.orange;
case CheckoutStatus.lost:
return Colors.red;
case CheckoutStatus.maintenance:
return Colors.purple;
}
}
}
enum EquipmentCondition {
excellent,
good,
fair,
poor,
damaged;
String get displayName {
switch (this) {
case EquipmentCondition.excellent:
return 'Excellent';
case EquipmentCondition.good:
return 'Good';
case EquipmentCondition.fair:
return 'Fair';
case EquipmentCondition.poor:
return 'Poor';
case EquipmentCondition.damaged:
return 'Damaged';
}
}
Color get color {
switch (this) {
case EquipmentCondition.excellent:
return Colors.green.shade700;
case EquipmentCondition.good:
return Colors.green;
case EquipmentCondition.fair:
return Colors.orange;
case EquipmentCondition.poor:
return Colors.red.shade300;
case EquipmentCondition.damaged:
return Colors.red;
}
}
}Firebase Integration
Firestore Collections
1. Equipment Collection (Existing): equipment
Used to fetch available equipment for checkout.
2. Equipment Checkouts Collection (New): equipment_checkouts
Document Structure:
{
"id": "auto-generated-id",
"equipmentId": "equipment-firebase-uid",
"equipmentName": "Excavator CAT 320",
"userId": "employee-user-id",
"userName": "John Smith",
"projectId": "project-firebase-uid",
"projectName": "Downtown Office Complex",
"checkoutTime": 1703123456789,
"checkinTime": null,
"status": "checkedOut",
"checkoutNotes": "Equipment in good condition",
"checkinNotes": null,
"checkoutPhotos": ["photo1_url", "photo2_url"],
"checkinPhotos": null,
"companyId": "company-workspace-id",
"location": "Job Site Address",
"checkoutCondition": "good",
"checkinCondition": null
}3. Equipment Status Collection (New): equipment_status
Real-time status tracking for equipment availability.
Document Structure:
{
"equipmentId": "equipment-firebase-uid",
"isAvailable": false,
"currentUserId": "employee-user-id",
"currentCheckoutId": "checkout-document-id",
"lastUpdated": 1703123456789,
"companyId": "company-workspace-id"
}Security Rules
// Equipment Checkouts - Employees can create/update their own, Admins can read all
match /equipment_checkouts/{checkoutId} {
allow read: if isAuthenticated() && (
isAdmin() ||
resource.data.userId == request.auth.uid ||
resource.data.companyId == getUserCompany()
);
allow create: if isAuthenticated() &&
request.auth.uid == resource.data.userId &&
resource.data.companyId == getUserCompany();
allow update: if isAuthenticated() && (
isAdmin() ||
(request.auth.uid == resource.data.userId &&
resource.data.companyId == getUserCompany())
);
allow delete: if isAdmin();
}
// Equipment Status - Real-time availability tracking
match /equipment_status/{equipmentId} {
allow read: if isAuthenticated() && resource.data.companyId == getUserCompany();
allow write: if isAuthenticated() && resource.data.companyId == getUserCompany();
}Mobile Implementation Guide
1. Equipment Service Layer
class EquipmentCheckoutService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
final FirebaseStorage _storage = FirebaseStorage.instance;
// Fetch available equipment for checkout
Future<List<Equipment>> getAvailableEquipment(String companyId) async {
try {
// Get all company equipment
final equipmentSnapshot = await _firestore
.collection('equipment')
.where('companyId', isEqualTo: companyId)
.where('isActive', isEqualTo: true)
.get();
final allEquipment = equipmentSnapshot.docs
.map((doc) => Equipment.fromJson(doc.id, doc.data()))
.toList();
// Get equipment status to filter available ones
final statusSnapshot = await _firestore
.collection('equipment_status')
.where('companyId', isEqualTo: companyId)
.where('isAvailable', isEqualTo: true)
.get();
final availableIds = statusSnapshot.docs
.map((doc) => doc.data()['equipmentId'] as String)
.toSet();
// Return equipment that is either available or has no status record (new equipment)
return allEquipment.where((equipment) =>
availableIds.contains(equipment.firebaseUid) ||
!statusSnapshot.docs.any((doc) =>
doc.data()['equipmentId'] == equipment.firebaseUid)
).toList();
} catch (e) {
print('Error fetching available equipment: $e');
return [];
}
}
// Check out equipment
Future<bool> checkoutEquipment({
required String equipmentId,
required String equipmentName,
required String userId,
required String userName,
required String companyId,
String? projectId,
String? projectName,
String? notes,
List<String>? photos,
String? location,
EquipmentCondition? condition,
}) async {
try {
final checkout = EquipmentCheckout(
equipmentId: equipmentId,
equipmentName: equipmentName,
userId: userId,
userName: userName,
projectId: projectId,
projectName: projectName,
checkoutTime: DateTime.now(),
status: CheckoutStatus.checkedOut,
checkoutNotes: notes,
checkoutPhotos: photos,
companyId: companyId,
location: location,
checkoutCondition: condition ?? EquipmentCondition.good,
);
// Use batch to ensure atomicity
final batch = _firestore.batch();
// Create checkout record
final checkoutRef = _firestore.collection('equipment_checkouts').doc();
batch.set(checkoutRef, checkout.copyWith(id: checkoutRef.id).toJson());
// Update equipment status
final statusRef = _firestore.collection('equipment_status').doc(equipmentId);
batch.set(statusRef, {
'equipmentId': equipmentId,
'isAvailable': false,
'currentUserId': userId,
'currentCheckoutId': checkoutRef.id,
'lastUpdated': DateTime.now().millisecondsSinceEpoch,
'companyId': companyId,
});
await batch.commit();
return true;
} catch (e) {
print('Error checking out equipment: $e');
return false;
}
}
// Check in equipment
Future<bool> checkinEquipment({
required String checkoutId,
String? notes,
List<String>? photos,
EquipmentCondition? condition,
}) async {
try {
// Get the checkout record
final checkoutDoc = await _firestore
.collection('equipment_checkouts')
.doc(checkoutId)
.get();
if (!checkoutDoc.exists) return false;
final checkout = EquipmentCheckout.fromJson({
'id': checkoutDoc.id,
...checkoutDoc.data()!,
});
// Use batch to ensure atomicity
final batch = _firestore.batch();
// Update checkout record
final updatedCheckout = checkout.copyWith(
checkinTime: DateTime.now(),
status: CheckoutStatus.checkedIn,
checkinNotes: notes,
checkinPhotos: photos,
checkinCondition: condition ?? EquipmentCondition.good,
);
batch.update(
_firestore.collection('equipment_checkouts').doc(checkoutId),
updatedCheckout.toJson(),
);
// Update equipment status
batch.update(
_firestore.collection('equipment_status').doc(checkout.equipmentId),
{
'isAvailable': true,
'currentUserId': null,
'currentCheckoutId': null,
'lastUpdated': DateTime.now().millisecondsSinceEpoch,
},
);
await batch.commit();
return true;
} catch (e) {
print('Error checking in equipment: $e');
return false;
}
}
// Get user's active checkouts
Future<List<EquipmentCheckout>> getUserActiveCheckouts(String userId) async {
try {
final snapshot = await _firestore
.collection('equipment_checkouts')
.where('userId', isEqualTo: userId)
.where('status', isEqualTo: 'checkedOut')
.orderBy('checkoutTime', descending: true)
.get();
return snapshot.docs
.map((doc) => EquipmentCheckout.fromJson({
'id': doc.id,
...doc.data(),
}))
.toList();
} catch (e) {
print('Error fetching user checkouts: $e');
return [];
}
}
// Upload photos to Firebase Storage
Future<List<String>> uploadPhotos(
List<File> photos, String checkoutId) async {
final List<String> urls = [];
for (int i = 0; i < photos.length; i++) {
try {
final ref = _storage
.ref()
.child('equipment_checkout_photos')
.child(checkoutId)
.child('photo_$i.jpg');
await ref.putFile(photos[i]);
final url = await ref.getDownloadURL();
urls.add(url);
} catch (e) {
print('Error uploading photo $i: $e');
}
}
return urls;
}
}2. Equipment Checkout Screen
class EquipmentCheckoutScreen extends StatefulWidget {
final String userId;
final String userName;
final String companyId;
final String? projectId;
final String? projectName;
const EquipmentCheckoutScreen({
Key? key,
required this.userId,
required this.userName,
required this.companyId,
this.projectId,
this.projectName,
}) : super(key: key);
@override
_EquipmentCheckoutScreenState createState() => _EquipmentCheckoutScreenState();
}
class _EquipmentCheckoutScreenState extends State<EquipmentCheckoutScreen> {
final EquipmentCheckoutService _checkoutService = EquipmentCheckoutService();
final TextEditingController _notesController = TextEditingController();
final TextEditingController _locationController = TextEditingController();
List<Equipment> availableEquipment = [];
Equipment? selectedEquipment;
EquipmentCondition selectedCondition = EquipmentCondition.good;
List<File> selectedPhotos = [];
bool isLoading = true;
bool isSubmitting = false;
@override
void initState() {
super.initState();
_loadAvailableEquipment();
}
Future<void> _loadAvailableEquipment() async {
final equipment = await _checkoutService.getAvailableEquipment(widget.companyId);
setState(() {
availableEquipment = equipment;
isLoading = false;
});
}
@override
Widget build(BuildContext context) {
if (isLoading) {
return Scaffold(
appBar: AppBar(title: Text('Equipment Checkout')),
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
title: Text('Equipment Checkout'),
backgroundColor: Colors.blue,
),
body: SingleChildScrollView(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildProjectInfo(),
SizedBox(height: 24),
_buildEquipmentSelection(),
SizedBox(height: 24),
_buildConditionSelection(),
SizedBox(height: 24),
_buildLocationInput(),
SizedBox(height: 24),
_buildNotesInput(),
SizedBox(height: 24),
_buildPhotoSection(),
SizedBox(height: 32),
_buildSubmitButton(),
],
),
),
);
}
Widget _buildProjectInfo() {
return Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Project Information',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text('User: ${widget.userName}'),
if (widget.projectName != null)
Text('Project: ${widget.projectName}'),
Text('Date: ${DateFormat('MMM dd, yyyy - hh:mm a').format(DateTime.now())}'),
],
),
),
);
}
Widget _buildEquipmentSelection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Select Equipment *',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
SizedBox(height: 8),
if (availableEquipment.isEmpty)
Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'No equipment available for checkout.',
style: TextStyle(color: Colors.grey),
),
),
)
else
...availableEquipment.map((equipment) =>
_buildEquipmentCard(equipment)
).toList(),
],
);
}
Widget _buildEquipmentCard(Equipment equipment) {
final isSelected = selectedEquipment?.firebaseUid == equipment.firebaseUid;
return Card(
margin: EdgeInsets.only(bottom: 8),
color: isSelected ? Colors.blue.shade50 : null,
child: ListTile(
leading: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: isSelected ? Colors.blue : Colors.grey,
width: 2,
),
color: isSelected ? Colors.blue : Colors.transparent,
),
child: isSelected
? Icon(Icons.check, color: Colors.white, size: 16)
: null,
),
title: Text(
equipment.name,
style: TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (equipment.type != null) Text('Type: ${equipment.type}'),
Text('Year: ${equipment.year}'),
Text('Rate: \$${equipment.hourRate.toStringAsFixed(2)}/hr'),
],
),
trailing: Icon(
Icons.construction,
color: isSelected ? Colors.blue : Colors.grey,
),
onTap: () {
setState(() {
selectedEquipment = isSelected ? null : equipment;
});
},
),
);
}
Widget _buildConditionSelection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Equipment Condition *',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
SizedBox(height: 8),
Wrap(
spacing: 8,
children: EquipmentCondition.values.map((condition) {
final isSelected = selectedCondition == condition;
return FilterChip(
label: Text(condition.displayName),
selected: isSelected,
selectedColor: condition.color.withOpacity(0.2),
checkmarkColor: condition.color,
onSelected: (selected) {
setState(() {
selectedCondition = condition;
});
},
);
}).toList(),
),
],
);
}
Widget _buildLocationInput() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Location',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
SizedBox(height: 8),
TextField(
controller: _locationController,
decoration: InputDecoration(
hintText: 'Enter job site or equipment location',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.location_on),
),
),
],
);
}
Widget _buildNotesInput() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Notes',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
SizedBox(height: 8),
TextField(
controller: _notesController,
maxLines: 3,
decoration: InputDecoration(
hintText: 'Add any notes about the equipment condition or checkout',
border: OutlineInputBorder(),
),
),
],
);
}
Widget _buildPhotoSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Photos',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
SizedBox(height: 8),
Row(
children: [
ElevatedButton.icon(
onPressed: _pickPhotos,
icon: Icon(Icons.camera_alt),
label: Text('Add Photos'),
),
SizedBox(width: 8),
if (selectedPhotos.isNotEmpty)
Text('${selectedPhotos.length} photo(s) selected'),
],
),
if (selectedPhotos.isNotEmpty) ...[
SizedBox(height: 8),
Container(
height: 80,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: selectedPhotos.length,
itemBuilder: (context, index) {
return Container(
margin: EdgeInsets.only(right: 8),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
selectedPhotos[index],
width: 80,
height: 80,
fit: BoxFit.cover,
),
),
Positioned(
right: 0,
top: 0,
child: GestureDetector(
onTap: () {
setState(() {
selectedPhotos.removeAt(index);
});
},
child: Container(
decoration: BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
child: Icon(
Icons.close,
color: Colors.white,
size: 16,
),
),
),
),
],
),
);
},
),
),
],
],
);
}
Widget _buildSubmitButton() {
final canSubmit = selectedEquipment != null && !isSubmitting;
return SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: canSubmit ? _performCheckout : null,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
padding: EdgeInsets.symmetric(vertical: 16),
),
child: isSubmitting
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
SizedBox(width: 8),
Text('Checking Out...'),
],
)
: Text(
'Check Out Equipment',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
);
}
Future<void> _pickPhotos() async {
final ImagePicker picker = ImagePicker();
final List<XFile>? images = await picker.pickMultiImage();
if (images != null) {
setState(() {
selectedPhotos.addAll(images.map((xfile) => File(xfile.path)));
});
}
}
Future<void> _performCheckout() async {
if (selectedEquipment == null) return;
setState(() => isSubmitting = true);
try {
// Upload photos first if any
List<String>? photoUrls;
if (selectedPhotos.isNotEmpty) {
final tempCheckoutId = DateTime.now().millisecondsSinceEpoch.toString();
photoUrls = await _checkoutService.uploadPhotos(selectedPhotos, tempCheckoutId);
}
// Perform checkout
final success = await _checkoutService.checkoutEquipment(
equipmentId: selectedEquipment!.firebaseUid,
equipmentName: selectedEquipment!.name,
userId: widget.userId,
userName: widget.userName,
companyId: widget.companyId,
projectId: widget.projectId,
projectName: widget.projectName,
notes: _notesController.text.trim().isEmpty
? null
: _notesController.text.trim(),
photos: photoUrls,
location: _locationController.text.trim().isEmpty
? null
: _locationController.text.trim(),
condition: selectedCondition,
);
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Equipment checked out successfully!'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context, true);
} else {
throw Exception('Checkout failed');
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to check out equipment. Please try again.'),
backgroundColor: Colors.red,
),
);
} finally {
setState(() => isSubmitting = false);
}
}
@override
void dispose() {
_notesController.dispose();
_locationController.dispose();
super.dispose();
}
}3. My Checkouts Screen
class MyCheckoutsScreen extends StatefulWidget {
final String userId;
final String companyId;
const MyCheckoutsScreen({
Key? key,
required this.userId,
required this.companyId,
}) : super(key: key);
@override
_MyCheckoutsScreenState createState() => _MyCheckoutsScreenState();
}
class _MyCheckoutsScreenState extends State<MyCheckoutsScreen> {
final EquipmentCheckoutService _checkoutService = EquipmentCheckoutService();
List<EquipmentCheckout> activeCheckouts = [];
bool isLoading = true;
@override
void initState() {
super.initState();
_loadActiveCheckouts();
}
Future<void> _loadActiveCheckouts() async {
final checkouts = await _checkoutService.getUserActiveCheckouts(widget.userId);
setState(() {
activeCheckouts = checkouts;
isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('My Equipment'),
backgroundColor: Colors.blue,
actions: [
IconButton(
icon: Icon(Icons.refresh),
onPressed: () {
setState(() => isLoading = true);
_loadActiveCheckouts();
},
),
],
),
body: isLoading
? Center(child: CircularProgressIndicator())
: activeCheckouts.isEmpty
? _buildEmptyState()
: ListView.builder(
padding: EdgeInsets.all(16),
itemCount: activeCheckouts.length,
itemBuilder: (context, index) {
return _buildCheckoutCard(activeCheckouts[index]);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EquipmentCheckoutScreen(
userId: widget.userId,
userName: 'Current User', // Get from user service
companyId: widget.companyId,
),
),
);
if (result == true) {
_loadActiveCheckouts();
}
},
child: Icon(Icons.add),
backgroundColor: Colors.blue,
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.construction,
size: 80,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'No Equipment Checked Out',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey.shade700,
),
),
SizedBox(height: 8),
Text(
'Tap the + button to check out equipment',
style: TextStyle(color: Colors.grey),
),
],
),
);
}
Widget _buildCheckoutCard(EquipmentCheckout checkout) {
final duration = DateTime.now().difference(checkout.checkoutTime);
final durationText = _formatDuration(duration);
return Card(
margin: EdgeInsets.only(bottom: 16),
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
checkout.equipmentName,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: checkout.status.color,
borderRadius: BorderRadius.circular(12),
),
child: Text(
checkout.status.displayName,
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
SizedBox(height: 8),
if (checkout.projectName != null)
Text('Project: ${checkout.projectName}'),
Text('Checked out: ${DateFormat('MMM dd, yyyy - hh:mm a').format(checkout.checkoutTime)}'),
Text('Duration: $durationText'),
if (checkout.location != null)
Text('Location: ${checkout.location}'),
if (checkout.checkoutCondition != null) ...[
SizedBox(height: 8),
Row(
children: [
Text('Condition: '),
Container(
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: checkout.checkoutCondition!.color.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: checkout.checkoutCondition!.color),
),
child: Text(
checkout.checkoutCondition!.displayName,
style: TextStyle(
color: checkout.checkoutCondition!.color,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
],
if (checkout.checkoutNotes != null) ...[
SizedBox(height: 8),
Text(
'Notes: ${checkout.checkoutNotes}',
style: TextStyle(fontStyle: FontStyle.italic),
),
],
SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => _showCheckoutDetails(checkout),
icon: Icon(Icons.visibility),
label: Text('View Details'),
),
),
SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: () => _showCheckinDialog(checkout),
icon: Icon(Icons.check),
label: Text('Check In'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
),
),
),
],
),
],
),
),
);
}
String _formatDuration(Duration duration) {
if (duration.inDays > 0) {
return '${duration.inDays}d ${duration.inHours % 24}h';
} else if (duration.inHours > 0) {
return '${duration.inHours}h ${duration.inMinutes % 60}m';
} else {
return '${duration.inMinutes}m';
}
}
void _showCheckoutDetails(EquipmentCheckout checkout) {
showDialog(
context: context,
builder: (context) => CheckoutDetailsDialog(checkout: checkout),
);
}
void _showCheckinDialog(EquipmentCheckout checkout) {
showDialog(
context: context,
builder: (context) => EquipmentCheckinDialog(
checkout: checkout,
onCheckinComplete: () {
_loadActiveCheckouts();
},
),
);
}
}4. Equipment Check-in Dialog
class EquipmentCheckinDialog extends StatefulWidget {
final EquipmentCheckout checkout;
final VoidCallback onCheckinComplete;
const EquipmentCheckinDialog({
Key? key,
required this.checkout,
required this.onCheckinComplete,
}) : super(key: key);
@override
_EquipmentCheckinDialogState createState() => _EquipmentCheckinDialogState();
}
class _EquipmentCheckinDialogState extends State<EquipmentCheckinDialog> {
final EquipmentCheckoutService _checkoutService = EquipmentCheckoutService();
final TextEditingController _notesController = TextEditingController();
EquipmentCondition selectedCondition = EquipmentCondition.good;
List<File> selectedPhotos = [];
bool isSubmitting = false;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Check In Equipment'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.checkout.equipmentName,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 16),
// Condition Selection
Text(
'Return Condition *',
style: TextStyle(fontWeight: FontWeight.w600),
),
SizedBox(height: 8),
Wrap(
spacing: 8,
children: EquipmentCondition.values.map((condition) {
final isSelected = selectedCondition == condition;
return FilterChip(
label: Text(
condition.displayName,
style: TextStyle(fontSize: 12),
),
selected: isSelected,
selectedColor: condition.color.withOpacity(0.2),
checkmarkColor: condition.color,
onSelected: (selected) {
setState(() {
selectedCondition = condition;
});
},
);
}).toList(),
),
SizedBox(height: 16),
// Notes
Text(
'Return Notes',
style: TextStyle(fontWeight: FontWeight.w600),
),
SizedBox(height: 8),
TextField(
controller: _notesController,
maxLines: 2,
decoration: InputDecoration(
hintText: 'Add notes about equipment condition or issues',
border: OutlineInputBorder(),
isDense: true,
),
),
SizedBox(height: 16),
// Photos
Text(
'Return Photos',
style: TextStyle(fontWeight: FontWeight.w600),
),
SizedBox(height: 8),
Row(
children: [
TextButton.icon(
onPressed: _pickPhotos,
icon: Icon(Icons.camera_alt),
label: Text('Add Photos'),
),
if (selectedPhotos.isNotEmpty)
Text('${selectedPhotos.length} selected'),
],
),
if (selectedPhotos.isNotEmpty) ...[
SizedBox(height: 8),
Container(
height: 60,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: selectedPhotos.length,
itemBuilder: (context, index) {
return Container(
margin: EdgeInsets.only(right: 8),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.file(
selectedPhotos[index],
width: 60,
height: 60,
fit: BoxFit.cover,
),
),
Positioned(
right: 0,
top: 0,
child: GestureDetector(
onTap: () {
setState(() {
selectedPhotos.removeAt(index);
});
},
child: Container(
decoration: BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
child: Icon(
Icons.close,
color: Colors.white,
size: 12,
),
),
),
),
],
),
);
},
),
),
],
],
),
),
actions: [
TextButton(
onPressed: isSubmitting ? null : () => Navigator.pop(context),
child: Text('Cancel'),
),
ElevatedButton(
onPressed: isSubmitting ? null : _performCheckin,
style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
child: isSubmitting
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text('Check In'),
),
],
);
}
Future<void> _pickPhotos() async {
final ImagePicker picker = ImagePicker();
final List<XFile>? images = await picker.pickMultiImage();
if (images != null) {
setState(() {
selectedPhotos.addAll(images.map((xfile) => File(xfile.path)));
});
}
}
Future<void> _performCheckin() async {
setState(() => isSubmitting = true);
try {
// Upload photos first if any
List<String>? photoUrls;
if (selectedPhotos.isNotEmpty) {
photoUrls = await _checkoutService.uploadPhotos(
selectedPhotos,
widget.checkout.id!,
);
}
// Perform checkin
final success = await _checkoutService.checkinEquipment(
checkoutId: widget.checkout.id!,
notes: _notesController.text.trim().isEmpty
? null
: _notesController.text.trim(),
photos: photoUrls,
condition: selectedCondition,
);
if (success) {
widget.onCheckinComplete();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Equipment checked in successfully!'),
backgroundColor: Colors.green,
),
);
} else {
throw Exception('Checkin failed');
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to check in equipment. Please try again.'),
backgroundColor: Colors.red,
),
);
} finally {
setState(() => isSubmitting = false);
}
}
@override
void dispose() {
_notesController.dispose();
super.dispose();
}
}Integration with Main App Flow
1. Navigation Integration
Add equipment checkout to your main navigation flow:
// In your main navigation/drawer
ListTile(
leading: Icon(Icons.construction),
title: Text('My Equipment'),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MyCheckoutsScreen(
userId: currentUser.id,
companyId: currentUser.companyId,
),
),
),
),2. Clock-In Integration
Integrate equipment checkout with your existing clock-in flow:
class ClockInService {
Future<bool> performClockIn({
required String userId,
required String companyId,
String? projectId,
}) async {
// 1. Perform normal clock-in
final clockInSuccess = await _performActualClockIn(userId, projectId);
if (clockInSuccess) {
// 2. Optionally prompt for equipment checkout
final shouldPromptEquipment = await _shouldPromptForEquipment(companyId);
if (shouldPromptEquipment) {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EquipmentCheckoutScreen(
userId: userId,
userName: currentUser.name,
companyId: companyId,
projectId: projectId,
),
),
);
}
}
return clockInSuccess;
}
}3. Clock-Out Integration
Ensure equipment is checked in before clock-out:
class ClockOutService {
Future<bool> performClockOut({
required String userId,
required String companyId,
}) async {
// 1. Check for active equipment checkouts
final checkoutService = EquipmentCheckoutService();
final activeCheckouts = await checkoutService.getUserActiveCheckouts(userId);
if (activeCheckouts.isNotEmpty) {
// 2. Prompt user to check in equipment first
final shouldContinue = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Equipment Not Returned'),
content: Text(
'You have ${activeCheckouts.length} equipment item(s) still checked out. '
'Please return all equipment before clocking out.'
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text('Cancel'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context, true);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MyCheckoutsScreen(
userId: userId,
companyId: companyId,
),
),
);
},
child: Text('Return Equipment'),
),
],
),
);
if (shouldContinue != true) return false;
}
// 3. Proceed with normal clock-out
return await _performActualClockOut(userId);
}
}Best Practices
1. Offline Support
class OfflineEquipmentService {
static const String CACHED_EQUIPMENT_KEY = 'cached_equipment';
static const String PENDING_CHECKOUTS_KEY = 'pending_checkouts';
Future<void> cacheEquipment(List<Equipment> equipment) async {
final prefs = await SharedPreferences.getInstance();
final equipmentJson = equipment.map((e) => e.toJson()).toList();
await prefs.setString(CACHED_EQUIPMENT_KEY, jsonEncode(equipmentJson));
}
Future<List<Equipment>> getCachedEquipment() async {
final prefs = await SharedPreferences.getInstance();
final cached = prefs.getString(CACHED_EQUIPMENT_KEY);
if (cached != null) {
final List<dynamic> equipmentJson = jsonDecode(cached);
return equipmentJson.map((json) => Equipment.fromJson('', json)).toList();
}
return [];
}
Future<void> savePendingCheckout(EquipmentCheckout checkout) async {
final prefs = await SharedPreferences.getInstance();
final existing = prefs.getStringList(PENDING_CHECKOUTS_KEY) ?? [];
existing.add(jsonEncode(checkout.toJson()));
await prefs.setStringList(PENDING_CHECKOUTS_KEY, existing);
}
Future<void> syncPendingCheckouts() async {
final prefs = await SharedPreferences.getInstance();
final pending = prefs.getStringList(PENDING_CHECKOUTS_KEY) ?? [];
for (String checkoutJson in pending) {
try {
final checkout = EquipmentCheckout.fromJson(jsonDecode(checkoutJson));
// Sync to Firebase
await _syncCheckoutToFirebase(checkout);
} catch (e) {
print('Error syncing checkout: $e');
}
}
// Clear synced checkouts
await prefs.remove(PENDING_CHECKOUTS_KEY);
}
}2. QR Code/Barcode Integration
class EquipmentScannerService {
Future<String?> scanEquipmentCode() async {
try {
final result = await BarcodeScanner.scan();
return result.rawContent;
} catch (e) {
print('Error scanning barcode: $e');
return null;
}
}
Future<Equipment?> getEquipmentByCode(String code, String companyId) async {
try {
final snapshot = await FirebaseFirestore.instance
.collection('equipment')
.where('companyId', isEqualTo: companyId)
.where('qrCode', isEqualTo: code) // Assuming equipment has QR codes
.limit(1)
.get();
if (snapshot.docs.isNotEmpty) {
return Equipment.fromJson(snapshot.docs.first.id, snapshot.docs.first.data());
}
} catch (e) {
print('Error fetching equipment by code: $e');
}
return null;
}
}3. Push Notifications
class EquipmentNotificationService {
static const String OVERDUE_NOTIFICATION_CHANNEL = 'equipment_overdue';
Future<void> scheduleOverdueReminder(EquipmentCheckout checkout) async {
final scheduledTime = checkout.checkoutTime.add(Duration(hours: 24)); // 24 hour reminder
await AwesomeNotifications().createNotification(
content: NotificationContent(
id: checkout.id.hashCode,
channelKey: OVERDUE_NOTIFICATION_CHANNEL,
title: 'Equipment Due Soon',
body: '${checkout.equipmentName} has been checked out for 24+ hours',
category: NotificationCategory.Reminder,
),
schedule: NotificationCalendar.fromDate(date: scheduledTime),
);
}
Future<void> cancelOverdueReminder(String checkoutId) async {
await AwesomeNotifications().cancel(checkoutId.hashCode);
}
}Testing Strategy
Unit Tests
group('EquipmentCheckoutService', () {
test('should checkout equipment successfully', () async {
final service = EquipmentCheckoutService();
final result = await service.checkoutEquipment(
equipmentId: 'test-equipment-id',
equipmentName: 'Test Equipment',
userId: 'test-user-id',
userName: 'Test User',
companyId: 'test-company-id',
);
expect(result, true);
});
test('should fetch user active checkouts', () async {
final service = EquipmentCheckoutService();
final checkouts = await service.getUserActiveCheckouts('test-user-id');
expect(checkouts, isNotEmpty);
expect(checkouts.first.status, CheckoutStatus.checkedOut);
});
});Integration Tests
group('Equipment Checkout Flow', () {
testWidgets('should complete full checkout flow', (tester) async {
await tester.pumpWidget(MyApp());
// Navigate to equipment checkout
await tester.tap(find.byIcon(Icons.construction));
await tester.pumpAndSettle();
// Select equipment
await tester.tap(find.text('Test Equipment'));
await tester.pumpAndSettle();
// Submit checkout
await tester.tap(find.text('Check Out Equipment'));
await tester.pumpAndSettle();
// Verify success message
expect(find.text('Equipment checked out successfully!'), findsOneWidget);
});
});Performance Considerations
1. Data Pagination
- Implement pagination for equipment lists and checkout history
- Use Firebase's limit() and startAfter() for efficient loading
2. Image Optimization
- Compress images before upload
- Generate thumbnails for photo previews
- Use lazy loading for photo galleries
3. Caching Strategy
- Cache frequently accessed equipment data
- Use memory cache for current session
- Implement cache invalidation on data updates
Security Considerations
1. Data Validation
- Validate equipment IDs before checkout/checkin
- Verify user permissions for equipment access
- Sanitize all user inputs
2. Photo Security
- Implement secure photo upload with virus scanning
- Use signed URLs for temporary photo access
- Apply proper access controls on storage buckets
3. Audit Trail
- Log all checkout/checkin activities
- Track equipment location changes
- Maintain complete history for compliance
This comprehensive plan provides everything needed to implement a robust equipment checkout feature on the mobile side, ensuring seamless integration with the existing SmartCrew admin system while maintaining security, performance, and user experience standards.