My App

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.

On this page