Skip to main content

Geofence Service — Data Layer

Covers the full geofence data pipeline: Retrofit API → DTOs → Mapper → Repository → DataStore → UI.


GeofenceApi

Retrofit interface at data/remote/api/GeofenceApi.kt. All methods are suspend.

interface GeofenceApi {

@GET("api/geofences")
suspend fun getGeofences(): List<GeofenceDto>

@GET("api/geofences")
suspend fun getGeofencesByDevice(@Query("deviceId") deviceId: Int): List<GeofenceDto>

@GET("api/geofences/{id}")
suspend fun getGeofence(@Path("id") id: Int): GeofenceDto

@POST("api/geofences")
suspend fun createGeofence(@Body request: CreateGeofenceRequest): GeofenceDto

@PUT("api/geofences/{id}")
suspend fun updateGeofence(@Path("id") id: Int, @Body request: UpdateGeofenceRequest): GeofenceDto

@DELETE("api/geofences/{id}")
suspend fun deleteGeofence(@Path("id") id: Int)
}
MethodHTTPPathBodyReturns
getGeofencesGETapi/geofencesList<GeofenceDto>
getGeofencesByDeviceGETapi/geofences?deviceId=List<GeofenceDto>
getGeofenceGETapi/geofences/{id}GeofenceDto
createGeofencePOSTapi/geofencesCreateGeofenceRequestGeofenceDto
updateGeofencePUTapi/geofences/{id}UpdateGeofenceRequestGeofenceDto
deleteGeofenceDELETEapi/geofences/{id}Unit

Geofence DTOs

All DTOs live in data/remote/dto/GeofenceDtos.kt.

Request Models

data class CreateGeofenceRequest(
val name: String,
val description: String? = null,
val area: String,
val deviceId: Int? = null,
val attributes: Map<String, Any>? = null
)

data class UpdateGeofenceRequest(
val name: String? = null,
val description: String? = null,
val area: String? = null,
val alertOnEnter: Boolean? = null,
val alertOnExit: Boolean? = null,
val attributes: Map<String, Any>? = null
)

CreateGeofenceRequest requires name and area; all other fields are optional. UpdateGeofenceRequest is fully optional — only non-null fields are sent as a partial update.

Response Model

data class GeofenceCenterDto(val lat: Double, val lon: Double)

data class GeofenceDto(
val id: Int,
val userId: Int? = null,
val name: String,
val description: String? = null,
val type: String? = null,
val center: GeofenceCenterDto? = null,
val radius: Double? = null,
val alertOnEnter: Boolean? = null,
val alertOnExit: Boolean? = null,
val deviceIds: List<Int>? = null,
val color: String? = null,
val createdAt: String? = null,
val area: String? = null,
@SerializedName("calendar_id")
val calendarId: Int? = null,
val attributes: Map<String, Any>? = null
)

area and calendarId are legacy fields kept for backward compatibility. calendarId uses @SerializedName("calendar_id") for JSON mapping.


GeofenceMapper

data/mappers/GeofenceMapper.kt@Singleton, injected with @Inject constructor().

Public Methods

MethodInputOutput
toDomain(dto: GeofenceDto)GeofenceDtoGeofence
toDomain(dtos: List<GeofenceDto>)List<GeofenceDto>List<Geofence>
toCreateRequest(data: GeofenceCreateData)GeofenceCreateDataCreateGeofenceRequest
toUpdateRequest(data: GeofenceUpdateData)GeofenceUpdateDataUpdateGeofenceRequest

Key Mapping Logic

toDomain(GeofenceDto) — maps every DTO field to the domain Geofence. Notable transformations:

  • typeGeofenceType.fromString(dto.type) (handles nullCIRCLE)
  • center → mapped to Coordinate(lat, lon) if non-null
  • userId → defaults to 0 when null
  • alertOnEnter, alertOnExit → default to true when null
  • deviceIds → defaults to emptyList() when null
  • attributes → defaults to emptyMap() when null

toCreateRequest — direct field mapping from GeofenceCreateData to CreateGeofenceRequest.

toUpdateRequest — direct field mapping from GeofenceUpdateData to UpdateGeofenceRequest.


Domain Entities

Defined in domain/entities/Geofence.kt.

enum class GeofenceType {
CIRCLE,
POLYGON;

companion object {
fun fromString(value: String?): GeofenceType = when (value?.lowercase()) {
"circle" -> CIRCLE
"polygon" -> POLYGON
else -> CIRCLE
}
}
}

data class Coordinate(val latitude: Double, val longitude: Double)

data class Geofence(
val id: Int,
val userId: Int,
val name: String,
val description: String?,
val type: GeofenceType = GeofenceType.CIRCLE,
val center: Coordinate? = null,
val radius: Double? = null,
val alertOnEnter: Boolean = true,
val alertOnExit: Boolean = true,
val deviceIds: List<Int> = emptyList(),
val color: String? = null,
val area: String? = null,
val attributes: Map<String, Any> = emptyMap()
)

data class GeofenceCreateData(
val name: String,
val description: String? = null,
val area: String,
val deviceId: Int? = null,
val attributes: Map<String, Any>? = null
)

data class GeofenceUpdateData(
val name: String? = null,
val description: String? = null,
val area: String? = null,
val alertOnEnter: Boolean? = null,
val alertOnExit: Boolean? = null,
val attributes: Map<String, Any>? = null
)

GeofenceRepository

Interface at domain/repositories/GeofenceRepository.kt. Defines the domain contract.

interface GeofenceRepository {
suspend fun getAll(): List<Geofence>
suspend fun getByDeviceId(deviceId: Int): List<Geofence>
suspend fun getById(id: Int): Geofence
suspend fun create(data: GeofenceCreateData): Geofence
suspend fun update(id: Int, data: GeofenceUpdateData): Geofence
suspend fun delete(id: Int)
}

GeofenceRepositoryImpl

data/repositories/GeofenceRepositoryImpl.kt@Singleton.

Constructor (3 dependencies)

@Singleton
class GeofenceRepositoryImpl @Inject constructor(
private val geofenceApi: GeofenceApi,
private val geofenceMapper: GeofenceMapper,
private val geofenceDataStore: GeofenceDataStore
) : GeofenceRepository

Extra Property

val geofencesFlow: StateFlow<List<Geofence>>
get() = geofenceDataStore.geofences

Exposes the GeofenceDataStore state flow for ViewModels to observe. Not part of the GeofenceRepository interface.

Method Implementations

MethodStrategy
getAll()Fetches all geofences from API, maps via geofenceMapper.toDomain(), stores via geofenceDataStore.setGeofences().
getByDeviceId(deviceId)Returns cached geofences from GeofenceDataStore if isDeviceLoaded(deviceId) is true. Otherwise fetches from API with ?deviceId= query, maps, and stores via setGeofencesForDevice().
refreshByDeviceId(deviceId)Invalidates the device cache via geofenceDataStore.invalidateDevice(), then always fetches from API and stores. Not part of the interface — called directly on the impl.
getById(id)Checks geofenceDataStore.getGeofence(id) first. On cache miss, fetches from API and maps. On 404 → GeofenceNotFoundException.
create(data)Maps GeofenceCreateDataCreateGeofenceRequest, POSTs to API, maps response. If data.deviceId is set but missing from the response's deviceIds, enriches the result (backend links asynchronously). Adds to GeofenceDataStore.
update(id, data)Maps GeofenceUpdateDataUpdateGeofenceRequest, PUTs to API, maps response, updates GeofenceDataStore. On 404 → GeofenceNotFoundException.
delete(id)DELETEs via API, then removes from GeofenceDataStore. On 404 → GeofenceNotFoundException.

Error Handling

All API-calling methods follow the same pattern:

  • HttpExceptionmapHttpException()NetworkException("HTTP error: {code}"), with special-case 404 → GeofenceNotFoundException where applicable (getById, update, delete).
  • IOExceptionNetworkException with contextual message.

Data Flow

API Fetch:    API → GeofenceMapper → GeofenceDataStore.setGeofences() → ViewModels (via StateFlow)
User Action: ViewModel → Repository → API → GeofenceDataStore.add/update/remove → all ViewModels

GeofenceDataStore

core/data/GeofenceDataStore.kt@Singleton, the single source of truth for geofence data. Bridges HTTP API calls into a unified observable state, similar to DeviceDataStore.

State Flows

PropertyTypeDescription
geofencesStateFlow<List<Geofence>>Observable list of all geofences
isInitializedStateFlow<Boolean>true after first data load; used for loading state

Per-Device Cache

An internal loadedDeviceIds: MutableSet<Int> tracks which devices have had their geofences loaded, enabling lazy per-device loading.

Initialization Methods

suspend fun setGeofences(geofences: List<Geofence>)
suspend fun setGeofencesForDevice(deviceId: Int, geofences: List<Geofence>)
fun isDeviceLoaded(deviceId: Int): Boolean
  • setGeofences() — replaces all geofences. Sets isInitialized = true.
  • setGeofencesForDevice() — merges geofences for a specific device. Removes existing geofences for the device, adds the new list, marks the device as loaded, and sets isInitialized = true if not already.
  • isDeviceLoaded() — returns whether geofences have been loaded for a device.

CRUD Methods

suspend fun addGeofence(geofence: Geofence)
suspend fun updateGeofence(geofence: Geofence)
suspend fun removeGeofence(id: Int)
  • addGeofence() — appends a new geofence. If a geofence with the same ID already exists, updates it instead.
  • updateGeofence() — replaces the geofence matching by id.
  • removeGeofence() — filters out the geofence by id.

Query Methods (synchronous)

fun getGeofencesByDevice(deviceId: Int): List<Geofence>
fun getGeofence(id: Int): Geofence?
  • getGeofencesByDevice() — filters geofences where deviceIds contains the given deviceId.
  • getGeofence() — finds a geofence by id, returns null if not found.

State Management

suspend fun clear()
fun invalidateDevice(deviceId: Int)
fun invalidate()
  • clear() — empties all data, resets isInitialized to false, clears loadedDeviceIds (used on logout).
  • invalidateDevice() — removes a device from loadedDeviceIds (forces re-fetch for that device).
  • invalidate() — sets isInitialized to false and clears loadedDeviceIds without clearing data.

Thread Safety

All mutating suspend methods use a Mutex to serialize writes. StateFlow.update {} ensures atomic compare-and-set on the underlying list.