Skip to content

[Bug]: Package triggers checkout occasionly from every branch in list #1675

@mostafa3bdelkaderARKLEAP

Description

Required Reading

  • Confirmed

Plugin Version

5.0.2

Flutter Doctor

[✓] Flutter (Channel stable, 3.35.4, on macOS 15.2 24C101 darwin-arm64, locale en-EG)
[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0-rc1)
[✓] Xcode - develop for iOS and macOS (Xcode 16.2)
[✓] Chrome - develop for the web
[✓] Connected device (2 available)
[✓] Network resources

Mobile operating-system(s)

  • iOS
  • Android

Device Manufacturer(s) and Model(s)

Samsung A56

Device operating-systems(s)

android 16

What happened?

At times, the user may experience check-out transactions from various branches included in their package, excluding the branch where they are currently located. For instance, if a user has access to three branches and checks in at one of them, the system may occasionally log check-out transactions for the other two branches. This occurs even though the user's current location is recorded with the latitude and longitude of the active branch, while the transactions are associated with the branch IDs of the other two locations.

Plugin Code and/or Config

bg.BackgroundGeolocation.onGeofence((bg.GeofenceEvent event) async {
      LocationServiceHandler.onGeofenceAction(event);
    });

    bg.BackgroundGeolocation.onConnectivityChange(
        (bg.ConnectivityChangeEvent event) async {
      LocationServiceHandler.onProviderChangeAction(event: event);
    });

    bg.BackgroundGeolocation.ready(
      bg.Config(
        desiredAccuracy: bg.Config.DESIRED_ACCURACY_HIGH,
        backgroundPermissionRationale: bg.PermissionRationale(
            title: "allow_app_access_to_your_location"
                .tr(namedArgs: {"appName": "We Attend"}),
            message: "location_message".tr()),
        enableHeadless: true,
        autoSync: false,
        distanceFilter: 20,
        stopOnTerminate: false,
        startOnBoot: Platform.isAndroid,
        debug: false,
        logLevel: bg.Config.LOG_LEVEL_VERBOSE,
        geofenceModeHighAccuracy: true,
      ),
    );


import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:flutter_background_geolocation/flutter_background_geolocation.dart'
    as bg;
import 'package:flutter_background_geolocation/flutter_background_geolocation.dart';
import 'package:internet_connection_checker_plus/internet_connection_checker_plus.dart';
import 'package:ntp/ntp.dart';
import 'package:people_mate_flutter/main.dart';
import 'package:people_mate_flutter/modules/attendance/model/attendance_cash_model.dart';
import 'package:safe_device/safe_device.dart';
import 'package:shared_preferences/shared_preferences.dart';

import '../../modules/attendance/cubit/cubit.dart';
import '../di/di.dart';
import '../local/cache.dart';
import '../local/data_base/attendance_data_base_model/attendance_database_helper.dart';
import '../network/repository.dart';

abstract class LocationServiceHandler {
  static Timer? _intensiveTrackingTimer;
  static DateTime? _timerStartTime;
  static Timer? _checkInResolutionTimer; // ADDED: Timer for check-in resolution

  // SharedPreferences keys
  static const String _remainingDurationKey =
      'intensive_tracking_remaining_duration';
  static const String _lastUpdateDateKey =
      'intensive_tracking_last_update_date';
  static const String _pendingCheckInsKey = 'pending_check_ins';

  /// triggers when client within range or out of range
  static onGeofenceAction(GeofenceEvent event) async {
    bool developerMode = await SafeDevice.isDevelopmentModeEnable;
    bool isMockLocation = await SafeDevice.isMockLocation;

    if (developerMode == false && isMockLocation == false) {
      GeofenceEvent geofenceEvent = event;

      if (Platform.isIOS) {
        if (geofenceEvent.action == "ENTER") {
          // Cancel any existing timer before starting with remaining duration
          _cancelIntensiveTrackingTimer();

          // Get remaining duration from SharedPreferences
          Duration remainingDuration = await _getRemainingDuration();

          // Check if it's a new day, reset if needed
          if (await _isNewDay()) {
            remainingDuration = Duration(hours: 8);
            await _saveRemainingDuration(remainingDuration);
            await _updateLastUpdateDate();
          }

          // Only proceed if there's remaining time
          if (remainingDuration > Duration.zero) {
            // Entered work location - enable intensive tracking
            await bg.BackgroundGeolocation.setConfig(bg.Config(
              preventSuspend: true,
              distanceFilter: 20,
              heartbeatInterval: 60,
            ));

            // Start timer with remaining duration from SharedPreferences
            _timerStartTime = DateTime.now();
            _intensiveTrackingTimer = Timer(remainingDuration, () async {
              await _disableIntensiveTracking();
              _intensiveTrackingTimer = null;
              // Reset for next day
              await _saveRemainingDuration(Duration(hours: 8));
              await _updateLastUpdateDate();
            });
          }
        } else if (geofenceEvent.action == "EXIT") {
          // Calculate remaining time and save to SharedPreferences
          await _updateAndSaveRemainingDuration();
          _cancelIntensiveTrackingTimer();

          // Left work location - return to battery-friendly mode
          await _disableIntensiveTracking();
        }
      }

      if (event.identifier.contains('Branch_ID:')) {
        try {
          await onProviderChangeAction();
        } catch (e) {
          print('Error in onProviderChangeAction:');
          print(e.toString());
        }

        if (event.action == 'ENTER') {
          // Handle check-in with consecutive check-in logic
          await _handleCheckIn(event);
        } else if (event.action == 'EXIT') {
          // Handle check-out and process any pending check-ins
          await _handleCheckOut(event);
        }
      }
    }
  }

  // MODIFIED: Handle check-in events with a 1-minute delay
  static Future<void> _handleCheckIn(GeofenceEvent event) async {
    debugPrint(
        "Check-in event received: ${event.identifier} at ${event.timestamp}");
    final prefs = await SharedPreferences.getInstance();
    final pendingCheckInsJson = prefs.getStringList(_pendingCheckInsKey) ?? [];

    List<Map<String, dynamic>> pendingCheckIns = pendingCheckInsJson
        .map((json) => Map<String, dynamic>.from(jsonDecode(json)))
        .toList();

    Map<String, dynamic> currentCheckIn = {
      'branchId': event.identifier.split(":").last,
      'latitude': event.location.coords.latitude,
      'longitude': event.location.coords.longitude,
      'timestamp': event.timestamp,
      'identifier': event.identifier,
    };

    pendingCheckIns.add(currentCheckIn);
    await _savePendingCheckIns(pendingCheckIns);

    // Cancel any existing timer because a new check-in has occurred
    _checkInResolutionTimer?.cancel();

    // Start a new 1-minute timer. This will fire if no other check-in/out
    // happens, resolving the check-in to the most recent one.
    _checkInResolutionTimer = Timer(const Duration(minutes: 1), () {
      _resolvePendingCheckIns();
    });
  }

  // ADDED: New method to process check-ins after the 1-minute delay
  static Future<void> _resolvePendingCheckIns() async {
    final prefs = await SharedPreferences.getInstance();
    final pendingCheckInsJson = prefs.getStringList(_pendingCheckInsKey) ?? [];

    if (pendingCheckInsJson.isNotEmpty) {
      List<Map<String, dynamic>> pendingCheckIns = pendingCheckInsJson
          .map((json) => Map<String, dynamic>.from(jsonDecode(json)))
          .toList();

      // After the wait, process only the MOST RECENT check-in
      final lastCheckIn = pendingCheckIns.last;
      await _processCheckIn(lastCheckIn);

      // Clear the pending list
      await prefs.remove(_pendingCheckInsKey);
    }
  }

// MODIFIED: Handle check-out events and resolve pending check-ins
  static Future<void> _handleCheckOut(GeofenceEvent event) async {
    debugPrint(
        "Check-out event received: ${event.identifier} at ${event.timestamp}");
    // Cancel the resolution timer, as a checkout event provides immediate resolution.
    _checkInResolutionTimer?.cancel();

    final prefs = await SharedPreferences.getInstance();
    final pendingCheckInsJson = prefs.getStringList(_pendingCheckInsKey) ?? [];

    // Only proceed if there are pending check-ins to resolve.
    if (pendingCheckInsJson.isNotEmpty) {
      List<Map<String, dynamic>> pendingCheckIns = pendingCheckInsJson
          .map((json) => Map<String, dynamic>.from(jsonDecode(json)))
          .toList();

      String checkOutBranchId = event.identifier.split(":").last;

      // Find check-ins that DON'T match the branch ID of the current check-out event.
      // These are considered the "valid" ones.
      List<Map<String, dynamic>> validCheckIns = pendingCheckIns
          .where((checkIn) => checkIn['branchId'] != checkOutBranchId)
          .toList();

      // If we found at least one valid check-in...
      if (validCheckIns.isNotEmpty) {
        // ...process each valid check-in.
        for (Map<String, dynamic> checkIn in validCheckIns) {
          await _processCheckIn(checkIn);
        }

        // Clear the list of pending check-ins.
        await prefs.remove(_pendingCheckInsKey);

        // Exit the function here. Do NOT process the checkout event itself.
        // Its job of resolving the ambiguity is done.
        return;
      }
    }

    // Process the checkout ONLY if there were no pending check-ins to resolve.
    // This handles the simple case: Enter A -> Exit A.
    await _processCheckOut(event);
  }

  // Process individual check-in
  static Future<void> _processCheckIn(Map<String, dynamic> checkInData) async {
    try {
      final f = await di<Repository>().recordAutoAttendance(
        latitude: checkInData['latitude'],
        longitude: checkInData['longitude'],
        branchId: int.parse(checkInData['branchId']),
        type: "check_in",
        isFromHome: false,
        isAutomatic: true,
      );

      f.fold((l) async {
        // Log transaction for debugging, cached transaction for offline
        AttendanceDBHelper.addLogTransactionModel(
          model: TransactionModel(
            branchId: checkInData['branchId'],
            worksPlaceLatitude: checkInData['latitude'],
            worksPlaceLongtitude: checkInData['longitude'],
            date: checkInData['timestamp'],
            checkType: "check_in",
            statusMessage: "Server Error === > ${l.toString()}",
            workPlaceName: checkInData['identifier'],
          ),
        );
        if (l.toString().contains("internet connection")) {
          DateTime? dateTimeNow = await getServerTime();
          AttendanceDBHelper.addCachedTransactionModel(
              model: TransactionModel(
                  branchId: checkInData['branchId'],
                  worksPlaceLatitude: checkInData['latitude'],
                  worksPlaceLongtitude: checkInData['longitude'],
                  date: dateTimeNow != null
                      ? dateTimeNow.toString()
                      : checkInData['timestamp'],
                  checkType: "check_in",
                  statusMessage: "Server Error === > ${l.toString()}",
                  workPlaceName: checkInData['identifier']));
        }
      }, (r) async {
        AttendanceDBHelper.addLogTransactionModel(
            model: TransactionModel(
                branchId: checkInData['branchId'],
                worksPlaceLatitude: checkInData['latitude'],
                worksPlaceLongtitude: checkInData['longitude'],
                date: checkInData['timestamp'],
                checkType: "check_in",
                statusMessage: "Success ${r.message.toString()}",
                workPlaceName: checkInData['identifier']));
        await AttendanceCubit.get(navigatorKey.currentState!.context)
            .getAttendanceDayHistory(dateTimeOfDay: DateTime.now());
      });
    } catch (e) {
      AttendanceDBHelper.addLogTransactionModel(
          model: TransactionModel(
        branchId: checkInData['branchId'],
        worksPlaceLatitude: checkInData['latitude'],
        worksPlaceLongtitude: checkInData['longitude'],
        date: checkInData['timestamp'],
        checkType: "check_in",
        statusMessage: "Server Error TRY === > ${e.toString()}",
      ));
    }
  }

  // Process check-out
  static Future<void> _processCheckOut(GeofenceEvent event) async {
    try {
      final f = await di<Repository>().recordAutoAttendance(
        latitude: event.location.coords.latitude,
        longitude: event.location.coords.longitude,
        branchId: int.parse(event.identifier.split(":").last),
        type: "check_out",
        isFromHome: false,
        isAutomatic: true,
      );

      f.fold((l) async {
        AttendanceDBHelper.addLogTransactionModel(
          model: TransactionModel(
            branchId: event.identifier.split(":").last,
            worksPlaceLatitude: event.location.coords.latitude,
            worksPlaceLongtitude: event.location.coords.longitude,
            date: event.timestamp,
            checkType: "check_out",
            statusMessage: "Server Error === > ${l.toString()}",
            workPlaceName: event.identifier,
          ),
        );
        if (l.toString().contains("internet connection")) {
          DateTime? dateTimeNow = await getServerTime();
          AttendanceDBHelper.addCachedTransactionModel(
              model: TransactionModel(
                  branchId: event.identifier.split(":").last,
                  worksPlaceLatitude: event.location.coords.latitude,
                  worksPlaceLongtitude: event.location.coords.longitude,
                  date: dateTimeNow != null
                      ? dateTimeNow.toString()
                      : event.timestamp,
                  checkType: "check_out",
                  statusMessage: "Server Error === > ${l.toString()}",
                  workPlaceName: event.identifier));
        }
      }, (r) async {
        AttendanceDBHelper.addLogTransactionModel(
            model: TransactionModel(
                branchId: event.identifier.split(":").last,
                worksPlaceLatitude: event.location.coords.latitude,
                worksPlaceLongtitude: event.location.coords.longitude,
                date: event.timestamp,
                checkType: "check_out",
                statusMessage: "Success ${r.message.toString()}",
                workPlaceName: event.identifier));
        await AttendanceCubit.get(navigatorKey.currentState!.context)
            .getAttendanceDayHistory(dateTimeOfDay: DateTime.now());
      });
    } catch (e) {
      AttendanceDBHelper.addLogTransactionModel(
          model: TransactionModel(
        branchId: event.identifier.split(":").last,
        worksPlaceLatitude: event.location.coords.latitude,
        worksPlaceLongtitude: event.location.coords.longitude,
        date: event.timestamp,
        checkType: "check_out",
        statusMessage: "Server Error TRY === > ${e.toString()}",
      ));
    }
  }

  // Save pending check-ins to SharedPreferences
  static Future<void> _savePendingCheckIns(
      List<Map<String, dynamic>> checkIns) async {
    final prefs = await SharedPreferences.getInstance();
    final jsonList = checkIns.map((checkIn) => jsonEncode(checkIn)).toList();
    await prefs.setStringList(_pendingCheckInsKey, jsonList);
  }

  // Get remaining duration from SharedPreferences
  static Future<Duration> _getRemainingDuration() async {
    final prefs = await SharedPreferences.getInstance();
    final hours = prefs.getDouble(_remainingDurationKey) ?? 8.0;
    return Duration(milliseconds: (hours * 3600000).round());
  }

  // Save remaining duration to SharedPreferences
  static Future<void> _saveRemainingDuration(Duration duration) async {
    final prefs = await SharedPreferences.getInstance();
    final hours = duration.inMilliseconds / 3600000.0;
    await prefs.setDouble(_remainingDurationKey, hours);
  }

  // Update last update date
  static Future<void> _updateLastUpdateDate() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_lastUpdateDateKey, DateTime.now().toIso8601String());
  }

  // Check if it's a new day
  static Future<bool> _isNewDay() async {
    final prefs = await SharedPreferences.getInstance();
    final lastUpdateString = prefs.getString(_lastUpdateDateKey);

    if (lastUpdateString == null) {
      return true; // First time usage
    }

    final lastUpdate = DateTime.parse(lastUpdateString);
    final today = DateTime.now();

    // Check if the date has changed
    return lastUpdate.day != today.day ||
        lastUpdate.month != today.month ||
        lastUpdate.year != today.year;
  }

  // Update remaining duration when exiting and save to SharedPreferences
  static Future<void> _updateAndSaveRemainingDuration() async {
    if (_timerStartTime != null) {
      final elapsedTime = DateTime.now().difference(_timerStartTime!);
      final currentRemaining = await _getRemainingDuration();
      final newRemaining = currentRemaining - elapsedTime;

      // Ensure remaining duration doesn't go below zero
      final finalRemaining =
          newRemaining.isNegative ? Duration.zero : newRemaining;

      // Save to SharedPreferences
      await _saveRemainingDuration(finalRemaining);
    }
  }

  // Cancel the intensive tracking timer
  static void _cancelIntensiveTrackingTimer() {
    if (_intensiveTrackingTimer != null && _intensiveTrackingTimer!.isActive) {
      _intensiveTrackingTimer!.cancel();
      _intensiveTrackingTimer = null;
    }
  }

  // Disable intensive tracking
  static Future<void> _disableIntensiveTracking() async {
    await BackgroundGeolocation.setConfig(bg.Config(
      preventSuspend: false,
      distanceFilter: 50,
      heartbeatInterval: -1,
    ));
  }

  // // Get pending check-ins (for debugging/monitoring)
  // static Future<List<Map<String, dynamic>>> getPendingCheckIns() async {
  //   final prefs = await SharedPreferences.getInstance();
  //   final pendingCheckInsJson = prefs.getStringList(_pendingCheckInsKey) ?? [];
  //   return pendingCheckInsJson
  //       .map((json) => Map<String, dynamic>.from(jsonDecode(json)))
  //       .toList();
  // }

  /// onProviderChangeAction: triggers when GPS or WIFI switched on or off
  /// This method fetch cached List of transactions and send them when user become online using recordMultiPleAutoAttendance Method
  static onProviderChangeAction({ConnectivityChangeEvent? event}) async {
    bool developerMode = await SafeDevice.isDevelopmentModeEnable;
    bool isMockLocation = await SafeDevice.isMockLocation;

    if (developerMode == false && isMockLocation == false) {
      List<TransactionModel> cachedTransactions = [];
      bool enabled = true;
      await BackgroundGeolocation.state.then((value) {
        enabled = value.enabled;
      });
      // if (event?.connected == false && !enabled) {
      //   await AttendanceDBHelper.getAttendanceModel().then((value) {
      //     if (value?.isAutomaticServiceEnabled == 1) {
      //       onEnabledChangeAction(val: false);
      //     }
      //   });
      // }
      if (await AttendanceDBHelper.getCachedTransactions() != null) {
        cachedTransactions =
            (await AttendanceDBHelper.getCachedTransactions())!;
      }

      if (cachedTransactions.isNotEmpty &&
          event?.connected == true &&
          enabled) {
        late StreamSubscription<InternetStatus> subscription;
        subscription = InternetConnection()
            .onStatusChange
            .listen((InternetStatus status) async {
          if (status == InternetStatus.connected) {
            final f = await di<Repository>().recordMultibleAutoAttendance(
                transactionList: cachedTransactions);
            f.fold((l) {
              // Do nothing on failure, keep cached transactions
              debugPrint(
                  "Failed to send cached transactions after switching to online : ${l.toString()}");
              AttendanceDBHelper.addLogTransactionModel(
                  model: TransactionModel(
                statusMessage:
                    "Failed to send cached transactions after switching to online: ${l.toString()}",
              ));
              if (!l.toString().contains("internet connection")) {
                AttendanceDBHelper.deleteCachedTransactionDatabase();
                subscription.cancel();
              }
            }, (r) {
              AttendanceDBHelper.deleteCachedTransactionDatabase();
              subscription.cancel();
            });
          }
        });
      }
    }
  }

  /// triggers when Geofencing service switched on or off
  // static onEnabledChangeAction({required bool val}) async {
  //   String? isAutomatic = await AppDatabaseHelper().getAutomaticServiceFlag();
  //   bool developerMode = await SafeDevice.isDevelopmentModeEnable;
  //   bool isMockLocation = await SafeDevice.isMockLocation;
  //   if (developerMode == false &&
  //       isMockLocation == false &&
  //       isAutomatic == "true") {
  //     AttendanceCacheModel? attendanceModel =
  //         await AttendanceDBHelper.getAttendanceModel();
  //     if (attendanceModel != null && !val) {
  //       print("Geofencing service disabled");
  //       if (attendanceModel.isAutomaticServiceEnabled == 1) {
  //         try {
  //           await BackgroundGeolocation.removeGeofences();
  //           List<Geofence> geofenceList = [];
  //           for (var element in attendanceModel.branchList!) {
  //             geofenceList.add(Geofence(
  //               identifier: 'Branch_ID:${element.id.toString()}',
  //               radius: element.radius ?? 100,
  //               latitude: element.latitude,
  //               longitude: element.longitude,
  //               notifyOnEntry: true,
  //               notifyOnExit: true,
  //             ));
  //           }
  //           await BackgroundGeolocation.addGeofences(
  //             geofenceList,
  //           );
  //         } catch (e) {
  //           debugPrint("Error in onEnabledChangeAction:");
  //           debugPrint(e.toString());
  //           AttendanceDBHelper.addLogTransactionModel(
  //               model: TransactionModel(
  //             statusMessage: "Error in onEnabledChangeAction: ${e.toString()}",
  //           ));
  //         }
  //       }
  //     }
  //   }
  // }
}

Future<DateTime?> getServerTime() async {
  DateTime? currentDateTime;
  try {
    final bool isConnected = await InternetConnection().hasInternetAccess;
    if (isConnected) {
      currentDateTime = await NTP.now();
      // Cache the time offset between NTP and device time
      final timeOffset = currentDateTime.difference(DateTime.now());
      di<CacheHelper>().put('time_offset', timeOffset.inMilliseconds);
    } else {
      // When offline, use device time + last known offset
      final timeOffset = di<CacheHelper>().get<int>('time_offset') ?? 0;
      currentDateTime = DateTime.now().add(Duration(milliseconds: timeOffset));
    }
  } catch (e) {
    debugPrint(e.toString());
  }
  return currentDateTime;
}

Relevant log output

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions