diff --git a/README.md b/README.md
index 4862b4fe..14195036 100644
--- a/README.md
+++ b/README.md
@@ -66,7 +66,8 @@ PRD 문서는 저장소에 없습니다. 사용자 확인에 따라 기존 READM
| `POST /api/v1/auth/team/login`, `POST /api/v1/auth/team/logout` | 연결 | 실제 로그인은 `TEAM_MEMBER` 세션을 사용합니다. 응답 `data{accessToken, expiresAt, user, siteId, teamId}`, refresh token 없음 |
| `GET·PATCH /auth/team/me` | 연결 | 응답 `{userId, name, email, phone, companyName, role, …, version}`. PATCH는 `name·email·phone` 중 하나 이상 필요, 앱은 `name`과 마지막으로 받은 `version`을 보냄. 409는 최신 정보 재조회 후 안내 |
| `PUT /auth/team/password` | 연결 | 요청 `{currentPassword, newPassword}`, 성공 `{changedAt}`, 현재 비밀번호 오류 401 `INVALID_CREDENTIALS`, 8자 미만 400. 변경 후에도 현재 세션 유지 |
-| `GET /team` | 연결 | 기상 미입력 시 기상·비교 필드는 null, 관리자 연락처는 빈 문자열일 수 있음. 응답 DTO가 `company.phone`, `weather.observedAt`·`skyStatus`·`temperatureDelta`·비교값, `unreadNotificationCount`를 파싱한다. 화면에서 사용하는 값만 Domain에 매핑한다. |
+| `GET /team` | 연결 | 기상 미입력 시 기상·비교 필드는 null, 관리자 연락처는 빈 문자열일 수 있음. 응답 DTO가 `company.phone`, `weather.observedAt`·`skyStatus`·`temperatureDelta`·비교값, `unreadNotificationCount`를 파싱한다. 화면에서 사용하는 값만 Domain에 매핑한다.
+| `POST /api/v1/team/weather` | 연결 | 홈에서 사용자가 coarse 위치 권한을 허용하면 현재 좌표를 숫자 JSON `{latitude, longitude}`으로 전송한다. `TEAM_MEMBER` Bearer 인증을 사용하고 `X-Team-Id`는 보내지 않아 선택 팀 기본값을 사용한다. 격자·KMA 키는 서버가 관리한다. `AVAILABLE`·`CACHED`만 홈 및 기록 기본값에 적용하고, `UNAVAILABLE`·요청/위치 실패·권한 거부는 `GET /team` 및 수동 입력으로 fallback한다. null 측정값은 null로 유지하며 관리자 저장 현장 설정은 변경하지 않는다. 사용자 요구, 2026-11-06. |
| `POST /team/uploads` → presigned `PUT` → `POST /team/records` | 연결 | 발급 요청은 `files[{contentType, size}]`이며, 실제 크기 1~5 MiB의 JPEG·PNG·WebP 사진을 1~2장 보냅니다. 응답 `uploads` 순서대로 `requiredHeaders`를 적용해 원본 바이너리를 PUT하고, 앱 Authorization은 보내지 않습니다. 응답의 `objectKey`를 기록 요청의 `photoKeys`에 사용하며 URL 만료 시각을 확인합니다. REST 기록은 필수로 선택한 `restStartedAt`·`restEndedAt`을 오프셋 포함 시각으로 보내며, 종료 시간이 같거나 이르면 다음 날로 처리. `restMinutes`는 서버가 계산 |
| `GET /team/records`, `GET /team/records/{recordId}` | 연결 | 목록은 선택적 `date=YYYY-MM-DD` 하루 단위 필터를 지원하고 `measuredAt`을 서울 날짜로 변환해 페이지네이션 전에 적용합니다. 기간·유형 쿼리는 지원하지 않음. 목록 항목에는 `photoKeys`만, 상세에는 `photoUrls` 추가. `restMinutes`·`teamName`·`workplace`·`siteName`은 기록 당시 값(이전 기록은 null) |
| `POST·GET /team/inquiries` | 연결 | 목록 항목 `{inquiryId, title, content, status, deliveryStatus, replies[], createdAt}`, 알 수 없는 cursor는 400 |
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3f226269..559c8b1a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
+
diff --git a/app/src/main/java/com/nativelap/heartguard/core/location/AndroidCurrentLocationProvider.kt b/app/src/main/java/com/nativelap/heartguard/core/location/AndroidCurrentLocationProvider.kt
new file mode 100644
index 00000000..ce74c0b5
--- /dev/null
+++ b/app/src/main/java/com/nativelap/heartguard/core/location/AndroidCurrentLocationProvider.kt
@@ -0,0 +1,66 @@
+package com.nativelap.heartguard.core.location
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.location.LocationManager
+import android.os.CancellationSignal
+import androidx.core.content.ContextCompat
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.InternalCoroutinesApi
+import kotlinx.coroutines.suspendCancellableCoroutine
+import javax.inject.Inject
+import kotlin.coroutines.resume
+
+@OptIn(InternalCoroutinesApi::class)
+class AndroidCurrentLocationProvider
+ @Inject
+ constructor(
+ @param:ApplicationContext private val context: Context,
+ ) : CurrentLocationProvider {
+ override suspend fun getCurrentLocation(): CurrentCoordinates? {
+ if (
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.ACCESS_COARSE_LOCATION,
+ ) != PackageManager.PERMISSION_GRANTED
+ ) {
+ return null
+ }
+
+ return try {
+ val locationManager = context.getSystemService(LocationManager::class.java)
+ if (!locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
+ return null
+ }
+
+ suspendCancellableCoroutine { continuation ->
+ val cancellationSignal = CancellationSignal()
+ continuation.invokeOnCancellation {
+ cancellationSignal.cancel()
+ }
+ locationManager.getCurrentLocation(
+ LocationManager.NETWORK_PROVIDER,
+ cancellationSignal,
+ context.mainExecutor,
+ ) { location ->
+ val coordinates =
+ location?.let { currentLocation ->
+ CurrentCoordinates(
+ latitude = currentLocation.latitude,
+ longitude = currentLocation.longitude,
+ )
+ }
+ val resumeToken = continuation.tryResume(coordinates)
+ if (resumeToken != null) {
+ continuation.completeResume(resumeToken)
+ }
+ }
+ }
+ } catch (exception: SecurityException) {
+ null
+ } catch (exception: IllegalArgumentException) {
+ null
+ }
+ }
+ }
diff --git a/app/src/main/java/com/nativelap/heartguard/core/location/CurrentCoordinates.kt b/app/src/main/java/com/nativelap/heartguard/core/location/CurrentCoordinates.kt
new file mode 100644
index 00000000..0c5eae1a
--- /dev/null
+++ b/app/src/main/java/com/nativelap/heartguard/core/location/CurrentCoordinates.kt
@@ -0,0 +1,6 @@
+package com.nativelap.heartguard.core.location
+
+data class CurrentCoordinates(
+ val latitude: Double,
+ val longitude: Double,
+)
diff --git a/app/src/main/java/com/nativelap/heartguard/core/location/CurrentLocationModule.kt b/app/src/main/java/com/nativelap/heartguard/core/location/CurrentLocationModule.kt
new file mode 100644
index 00000000..691f9208
--- /dev/null
+++ b/app/src/main/java/com/nativelap/heartguard/core/location/CurrentLocationModule.kt
@@ -0,0 +1,15 @@
+package com.nativelap.heartguard.core.location
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class CurrentLocationModule {
+ @Binds
+ @Singleton
+ abstract fun bindCurrentLocationProvider(implementation: AndroidCurrentLocationProvider): CurrentLocationProvider
+}
diff --git a/app/src/main/java/com/nativelap/heartguard/core/location/CurrentLocationProvider.kt b/app/src/main/java/com/nativelap/heartguard/core/location/CurrentLocationProvider.kt
new file mode 100644
index 00000000..766cacad
--- /dev/null
+++ b/app/src/main/java/com/nativelap/heartguard/core/location/CurrentLocationProvider.kt
@@ -0,0 +1,5 @@
+package com.nativelap.heartguard.core.location
+
+interface CurrentLocationProvider {
+ suspend fun getCurrentLocation(): CurrentCoordinates?
+}
diff --git a/app/src/main/java/com/nativelap/heartguard/navigation/HeartGuardNavHost.kt b/app/src/main/java/com/nativelap/heartguard/navigation/HeartGuardNavHost.kt
index 07f4c8c5..cb06d566 100644
--- a/app/src/main/java/com/nativelap/heartguard/navigation/HeartGuardNavHost.kt
+++ b/app/src/main/java/com/nativelap/heartguard/navigation/HeartGuardNavHost.kt
@@ -901,6 +901,7 @@ private fun HeartGuardMainNavDisplay(
entry {
HeartGuardFieldPhotoRoute(
onBackClick = ::goBack,
+ homeViewModel = homeViewModel,
onNotificationClick = { backStack.add(HeartGuardDestination.TeamNotifications) },
recordDraftViewModel = recordDraftViewModel,
onSaveSuccess = ::goToSaveSuccess,
@@ -910,6 +911,7 @@ private fun HeartGuardMainNavDisplay(
entry {
HeartGuardWorkPhotoRoute(
onBackClick = ::goBack,
+ homeViewModel = homeViewModel,
onNotificationClick = { backStack.add(HeartGuardDestination.TeamNotifications) },
recordDraftViewModel = recordDraftViewModel,
onSaveSuccess = ::goToSaveSuccess,
@@ -919,6 +921,7 @@ private fun HeartGuardMainNavDisplay(
entry {
HeartGuardRestPhotoRoute(
onBackClick = ::goBack,
+ homeViewModel = homeViewModel,
onNotificationClick = { backStack.add(HeartGuardDestination.TeamNotifications) },
recordDraftViewModel = recordDraftViewModel,
onSaveSuccess = ::goToSaveSuccess,
diff --git a/app/src/main/java/com/nativelap/heartguard/view/route/home/HomeRoute.kt b/app/src/main/java/com/nativelap/heartguard/view/route/home/HomeRoute.kt
index b755917f..6d033ae5 100644
--- a/app/src/main/java/com/nativelap/heartguard/view/route/home/HomeRoute.kt
+++ b/app/src/main/java/com/nativelap/heartguard/view/route/home/HomeRoute.kt
@@ -1,7 +1,11 @@
package com.nativelap.heartguard.view.route.home
+import android.Manifest
+import android.content.pm.PackageManager
import android.widget.Toast
import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -16,6 +20,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
+import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -65,6 +70,15 @@ internal fun HeartGuardHomeRoute(
unreadViewModel: TeamNotificationUnreadViewModel = hiltViewModel(),
) {
val context = LocalContext.current
+ var hasRequestedLocationPermission by rememberSaveable { mutableStateOf(false) }
+ val locationPermissionLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
+ if (isGranted) {
+ homeViewModel.loadCurrentLocationWeather()
+ } else {
+ homeViewModel.clearCurrentLocationWeather()
+ }
+ }
val homeUiState by homeViewModel.uiState.collectAsStateWithLifecycle()
val siteStatus by homeViewModel.siteStatus.collectAsStateWithLifecycle()
val checkSchedule by homeViewModel.checkSchedule.collectAsStateWithLifecycle()
@@ -74,10 +88,35 @@ internal fun HeartGuardHomeRoute(
val unreadUiState by unreadViewModel.uiState.collectAsStateWithLifecycle()
val loadFailureMessage = stringResource(R.string.home_load_failure)
+ LaunchedEffect(homeViewModel, locationPermissionLauncher) {
+ if (!hasRequestedLocationPermission) {
+ hasRequestedLocationPermission = true
+ val permissionGranted =
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.ACCESS_COARSE_LOCATION,
+ ) == PackageManager.PERMISSION_GRANTED
+ if (!permissionGranted) {
+ locationPermissionLauncher.launch(Manifest.permission.ACCESS_COARSE_LOCATION)
+ }
+ }
+ }
+
// 기록 저장 뒤나 앱을 다시 열었을 때 날씨·체크 완료가 갱신되도록 홈이 다시 보일 때마다 새로 조회한다.
LifecycleResumeEffect(homeViewModel) {
homeViewModel.refresh()
unreadViewModel.refresh()
+ val isLocationPermissionGranted =
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.ACCESS_COARSE_LOCATION,
+ ) == PackageManager.PERMISSION_GRANTED
+
+ if (isLocationPermissionGranted) {
+ homeViewModel.loadCurrentLocationWeather()
+ } else {
+ homeViewModel.clearCurrentLocationWeather()
+ }
onPauseOrDispose {}
}
diff --git a/app/src/main/java/com/nativelap/heartguard/view/route/photo/PhotoRoutes.kt b/app/src/main/java/com/nativelap/heartguard/view/route/photo/PhotoRoutes.kt
index 3317c6e1..dbe4b4e4 100644
--- a/app/src/main/java/com/nativelap/heartguard/view/route/photo/PhotoRoutes.kt
+++ b/app/src/main/java/com/nativelap/heartguard/view/route/photo/PhotoRoutes.kt
@@ -16,6 +16,7 @@ import com.nativelap.heartguard.view.route.record.rememberRecordSubmitter
import com.nativelap.heartguard.view.screen.photo.FieldPhotoScreen
import com.nativelap.heartguard.view.screen.photo.RestPhotoScreen
import com.nativelap.heartguard.view.screen.photo.WorkPhotoScreen
+import com.nativelap.heartguard.viewmodel.home.HomeViewModel
import com.nativelap.heartguard.viewmodel.record.RecordDraftViewModel
import com.nativelap.heartguard.viewmodel.record.RecordSubmissionState
import com.nativelap.heartguard.viewmodel.record.canAttemptSubmission
@@ -27,18 +28,22 @@ import com.nativelap.heartguard.viewmodel.record.canAttemptSubmission
@Composable
internal fun HeartGuardFieldPhotoRoute(
recordDraftViewModel: RecordDraftViewModel,
+ homeViewModel: HomeViewModel,
onSaveSuccess: () -> Unit,
onSaveFailure: () -> Unit,
onBackClick: () -> Unit,
onNotificationClick: () -> Unit,
) {
val draftState by recordDraftViewModel.uiState.collectAsStateWithLifecycle()
+ val siteStatus by homeViewModel.siteStatus.collectAsStateWithLifecycle()
val submissionState by recordDraftViewModel.submissionState.collectAsStateWithLifecycle()
val submitRecord =
rememberRecordSubmitter(
recordDraftViewModel = recordDraftViewModel,
onSaveSuccess = onSaveSuccess,
onSaveFailure = onSaveFailure,
+ temperature = siteStatus.recordTemperature,
+ humidity = siteStatus.recordHumidity,
)
var showSaveError by rememberSaveable {
mutableStateOf(false)
@@ -96,18 +101,22 @@ internal fun HeartGuardFieldPhotoRoute(
@Composable
internal fun HeartGuardWorkPhotoRoute(
recordDraftViewModel: RecordDraftViewModel,
+ homeViewModel: HomeViewModel,
onSaveSuccess: () -> Unit,
onSaveFailure: () -> Unit,
onBackClick: () -> Unit,
onNotificationClick: () -> Unit,
) {
val draftState by recordDraftViewModel.uiState.collectAsStateWithLifecycle()
+ val siteStatus by homeViewModel.siteStatus.collectAsStateWithLifecycle()
val submissionState by recordDraftViewModel.submissionState.collectAsStateWithLifecycle()
val submitRecord =
rememberRecordSubmitter(
recordDraftViewModel = recordDraftViewModel,
onSaveSuccess = onSaveSuccess,
onSaveFailure = onSaveFailure,
+ temperature = siteStatus.recordTemperature,
+ humidity = siteStatus.recordHumidity,
)
PhotoSelectionFlow(
@@ -143,18 +152,22 @@ internal fun HeartGuardWorkPhotoRoute(
@Composable
internal fun HeartGuardRestPhotoRoute(
recordDraftViewModel: RecordDraftViewModel,
+ homeViewModel: HomeViewModel,
onSaveSuccess: () -> Unit,
onSaveFailure: () -> Unit,
onBackClick: () -> Unit,
onNotificationClick: () -> Unit,
) {
val draftState by recordDraftViewModel.uiState.collectAsStateWithLifecycle()
+ val siteStatus by homeViewModel.siteStatus.collectAsStateWithLifecycle()
val submissionState by recordDraftViewModel.submissionState.collectAsStateWithLifecycle()
val submitRecord =
rememberRecordSubmitter(
recordDraftViewModel = recordDraftViewModel,
onSaveSuccess = onSaveSuccess,
onSaveFailure = onSaveFailure,
+ temperature = siteStatus.recordTemperature,
+ humidity = siteStatus.recordHumidity,
)
var isSelectingRestStart by rememberSaveable { mutableStateOf(false) }
var isSelectingRestEnd by rememberSaveable { mutableStateOf(false) }
diff --git a/app/src/main/java/com/nativelap/heartguard/view/route/record/RecordRoutes.kt b/app/src/main/java/com/nativelap/heartguard/view/route/record/RecordRoutes.kt
index 2e4769d3..80c3b7d8 100644
--- a/app/src/main/java/com/nativelap/heartguard/view/route/record/RecordRoutes.kt
+++ b/app/src/main/java/com/nativelap/heartguard/view/route/record/RecordRoutes.kt
@@ -74,6 +74,8 @@ internal fun HeartGuardTemperatureRecordRoute(
recordDraftViewModel = recordDraftViewModel,
onSaveSuccess = onSaveSuccess,
onSaveFailure = onSaveFailure,
+ temperature = siteStatus.recordTemperature,
+ humidity = siteStatus.recordHumidity,
)
TemperatureRecordScreen(
diff --git a/app/src/main/java/com/nativelap/heartguard/view/route/record/RecordSubmitter.kt b/app/src/main/java/com/nativelap/heartguard/view/route/record/RecordSubmitter.kt
index a7c97740..55fd1571 100644
--- a/app/src/main/java/com/nativelap/heartguard/view/route/record/RecordSubmitter.kt
+++ b/app/src/main/java/com/nativelap/heartguard/view/route/record/RecordSubmitter.kt
@@ -18,10 +18,14 @@ internal fun rememberRecordSubmitter(
recordDraftViewModel: RecordDraftViewModel,
onSaveSuccess: () -> Unit,
onSaveFailure: () -> Unit,
+ temperature: Double? = null,
+ humidity: Double? = null,
): () -> Unit {
val lifecycleOwner = LocalLifecycleOwner.current
val latestOnSaveSuccess by rememberUpdatedState(onSaveSuccess)
val latestOnSaveFailure by rememberUpdatedState(onSaveFailure)
+ val latestTemperature by rememberUpdatedState(temperature)
+ val latestHumidity by rememberUpdatedState(humidity)
LaunchedEffect(recordDraftViewModel, lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
@@ -34,5 +38,10 @@ internal fun rememberRecordSubmitter(
}
}
- return recordDraftViewModel::submit
+ return {
+ recordDraftViewModel.submit(
+ defaultTemperature = latestTemperature,
+ defaultHumidity = latestHumidity,
+ )
+ }
}
diff --git a/app/src/main/java/com/nativelap/heartguard/viewmodel/home/HomeViewModel.kt b/app/src/main/java/com/nativelap/heartguard/viewmodel/home/HomeViewModel.kt
index eb9753e8..999a5045 100644
--- a/app/src/main/java/com/nativelap/heartguard/viewmodel/home/HomeViewModel.kt
+++ b/app/src/main/java/com/nativelap/heartguard/viewmodel/home/HomeViewModel.kt
@@ -2,13 +2,17 @@ package com.nativelap.heartguard.viewmodel.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.nativelap.heartguard.core.location.CurrentLocationProvider
import com.nativelap.heartguard.core.network.ApiResult
import com.nativelap.heartguard.core.session.SessionManager
import com.nativelap.heartguard.core.session.SessionState
import com.nativelap.heartguard.core.session.clearStateWhenSessionEnds
import com.nativelap.heartguard.domain.record.usecase.GetRecordHistoryUseCase
import com.nativelap.heartguard.domain.site.model.CheckSchedule
+import com.nativelap.heartguard.domain.site.model.CurrentLocationWeather
+import com.nativelap.heartguard.domain.site.model.WeatherAvailability
import com.nativelap.heartguard.domain.site.model.buildCheckSchedule
+import com.nativelap.heartguard.domain.site.usecase.GetCurrentLocationWeatherUseCase
import com.nativelap.heartguard.domain.site.usecase.GetTeamSiteOverviewUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
@@ -39,6 +43,8 @@ class HomeViewModel
private val getRecordHistoryUseCase: GetRecordHistoryUseCase,
private val sessionManager: SessionManager,
private val clock: Clock,
+ private val currentLocationProvider: CurrentLocationProvider,
+ private val getCurrentLocationWeatherUseCase: GetCurrentLocationWeatherUseCase,
) : ViewModel() {
private val mutableUiState = MutableStateFlow(HomeUiState.Loading)
val uiState: StateFlow = mutableUiState.asStateFlow()
@@ -46,15 +52,17 @@ class HomeViewModel
// 오늘 저장한 기록의 측정 시각(현지 시각)이다. 체크 타임라인 완료 표시에 쓰며, 받지 못했으면 비어 있다.
private val todayRecordTimes = MutableStateFlow>(emptyList())
+ private val currentLocationWeather = MutableStateFlow(null)
+
/** 여러 화면이 공통으로 쓰는 현장 값. 응답이 없으면 모든 값이 null이다. */
val siteStatus: StateFlow =
- uiState
- .map { homeUiState -> homeUiState.toSiteStatusUiModel() }
- .stateIn(
- scope = viewModelScope,
- started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
- initialValue = SiteStatusUiModel(),
- )
+ combine(uiState, currentLocationWeather) { homeUiState, weather ->
+ homeUiState.toSiteStatusUiModel(weather)
+ }.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
+ initialValue = SiteStatusUiModel(),
+ )
// 화면이 떠 있는 동안 1분마다 현재 시각을 흘려보내 "다음 체크까지 N분" 문구와 현재 체크 표시를 갱신한다.
private val currentTime =
@@ -89,6 +97,9 @@ class HomeViewModel
)
private var loadJob: Job? = null
+ private var currentLocationWeatherJob: Job? = null
+ private var currentLocationWeatherGeneration: Long? = null
+ private var currentLocationWeatherRequestId = 0L
// 진행 중인 조회를 시작한 세션 세대다. 계정이 바뀐 뒤 남은 이전 조회 때문에 새 조회를 건너뛰지 않게 한다.
private var loadJobGeneration: Long? = null
@@ -122,6 +133,56 @@ class HomeViewModel
startLoad(shouldRestartRunningLoad = false)
}
+ /** 권한을 승인한 경우에만 홈에서 호출해 현재 좌표 날씨를 한 번 조회한다. */
+ fun loadCurrentLocationWeather() {
+ val generation = sessionManager.getSnapshot().generation
+ if (currentLocationWeatherJob?.isActive == true &&
+ currentLocationWeatherGeneration == generation
+ ) {
+ return
+ }
+ currentLocationWeatherJob?.cancel()
+ currentLocationWeather.value = null
+ currentLocationWeatherGeneration = generation
+ val requestId = ++currentLocationWeatherRequestId
+ currentLocationWeatherJob =
+ viewModelScope.launch {
+ val coordinates = currentLocationProvider.getCurrentLocation() ?: return@launch
+ if (requestId != currentLocationWeatherRequestId ||
+ generation != sessionManager.getSnapshot().generation
+ ) {
+ return@launch
+ }
+ val result =
+ getCurrentLocationWeatherUseCase(
+ coordinates.latitude,
+ coordinates.longitude,
+ )
+ if (requestId != currentLocationWeatherRequestId ||
+ generation != sessionManager.getSnapshot().generation
+ ) {
+ return@launch
+ }
+ if (result is ApiResult.Success &&
+ (
+ result.value.availability == WeatherAvailability.AVAILABLE ||
+ result.value.availability == WeatherAvailability.CACHED
+ )
+ ) {
+ currentLocationWeather.value = result.value
+ }
+ }
+ }
+
+ /** 위치 권한이 없거나 철회된 경우 진행 중 요청과 위치 날씨 override를 제거한다. */
+ fun clearCurrentLocationWeather() {
+ currentLocationWeatherRequestId += 1
+ currentLocationWeatherJob?.cancel()
+ currentLocationWeatherJob = null
+ currentLocationWeatherGeneration = null
+ currentLocationWeather.value = null
+ }
+
/** 자정 이후에는 전날 완료 기록을 제거한 뒤 오늘 일정과 기록을 다시 불러온다. */
fun refreshForNewDay() {
todayRecordTimes.value = emptyList()
@@ -181,6 +242,7 @@ class HomeViewModel
/** 세션이 바뀌거나 메인 흐름을 벗어날 때 이전 팀의 응답과 진행 중인 요청을 제거한다. */
fun reset() {
loadJob?.cancel()
+ clearCurrentLocationWeather()
loadJob = null
loadJobGeneration = null
mutableUiState.value = HomeUiState.Loading
diff --git a/app/src/main/java/com/nativelap/heartguard/viewmodel/home/SiteStatusUiModel.kt b/app/src/main/java/com/nativelap/heartguard/viewmodel/home/SiteStatusUiModel.kt
index d036d02e..14014c02 100644
--- a/app/src/main/java/com/nativelap/heartguard/viewmodel/home/SiteStatusUiModel.kt
+++ b/app/src/main/java/com/nativelap/heartguard/viewmodel/home/SiteStatusUiModel.kt
@@ -1,8 +1,10 @@
package com.nativelap.heartguard.viewmodel.home
import androidx.compose.runtime.Immutable
+import com.nativelap.heartguard.domain.site.model.CurrentLocationWeather
import com.nativelap.heartguard.domain.site.model.SkyStatus
import com.nativelap.heartguard.domain.site.model.TeamSiteOverview
+import com.nativelap.heartguard.domain.site.model.WeatherAvailability
import com.nativelap.heartguard.viewmodel.toDisplayNumber
/** 팀 현장페이지 응답 중 여러 화면(홈·온도계 기록·현장 사진·긴급 호출)이 함께 보여주는 값이다.
@@ -19,19 +21,35 @@ data class SiteStatusUiModel(
val workplace: String? = null,
val headquartersPhoneNumber: String? = null,
val skyStatus: SkyStatus? = null,
- // "+3.2", "-1.5"처럼 부호가 붙은 변화량이다. 0이면 부호 없이 "0"이다.
val temperatureDelta: String? = null,
- // 변화량 방향이다. 변화량이 없거나 0이면 null이라 화살표 없이 중립 색으로 보여준다.
val isTemperatureIncreasing: Boolean? = null,
- // 온도 변화량이 정확히 0인지 나타낸다. 값 누락과 구분해 배지 색상을 선택한다.
val isTemperatureDeltaZero: Boolean = false,
+ val recordTemperature: Double? = null,
+ val recordHumidity: Double? = null,
)
-/** 홈 화면 상태를 공용 현장 상태로 바꾼다. Success가 아니면 모든 값이 null이다. */
-fun HomeUiState.toSiteStatusUiModel(): SiteStatusUiModel {
+/** 홈 화면 상태와 현재 위치 날씨를 공용 현장 상태로 바꾼다. */
+fun HomeUiState.toSiteStatusUiModel(locationWeather: CurrentLocationWeather? = null): SiteStatusUiModel {
val overview = (this as? HomeUiState.Success)?.overview ?: return SiteStatusUiModel()
+ val siteStatus = overview.toSiteStatusUiModel()
+ val availableLocationWeather =
+ locationWeather?.takeIf { weather ->
+ weather.availability == WeatherAvailability.AVAILABLE ||
+ weather.availability == WeatherAvailability.CACHED
+ } ?: return siteStatus
- return overview.toSiteStatusUiModel()
+ return siteStatus.copy(
+ temperature = availableLocationWeather.temperature.toDisplayNumber(),
+ humidity = availableLocationWeather.humidity.toDisplayNumber(),
+ apparentTemperature = availableLocationWeather.apparentTemperature.toDisplayNumber(),
+ heatLevel = availableLocationWeather.heatLevel,
+ skyStatus = availableLocationWeather.skyStatus,
+ temperatureDelta = availableLocationWeather.temperatureDelta?.toSignedDisplayNumber(),
+ isTemperatureIncreasing = availableLocationWeather.temperatureDelta?.toIncreasingDirection(),
+ isTemperatureDeltaZero = availableLocationWeather.temperatureDelta == 0.0,
+ recordTemperature = availableLocationWeather.temperature,
+ recordHumidity = availableLocationWeather.humidity,
+ )
}
private fun TeamSiteOverview.toSiteStatusUiModel(): SiteStatusUiModel =
@@ -48,11 +66,13 @@ private fun TeamSiteOverview.toSiteStatusUiModel(): SiteStatusUiModel =
temperatureDelta = temperatureDelta?.toSignedDisplayNumber(),
isTemperatureIncreasing = temperatureDelta?.toIncreasingDirection(),
isTemperatureDeltaZero = temperatureDelta == 0.0,
+ recordTemperature = currentTemperature,
+ recordHumidity = humidity,
)
private fun Double.toSignedDisplayNumber(): String =
if (this > 0.0) {
- "+${toDisplayNumber()}"
+ "+".plus(toDisplayNumber())
} else {
toDisplayNumber()
}
diff --git a/app/src/main/java/com/nativelap/heartguard/viewmodel/record/RecordDraftViewModel.kt b/app/src/main/java/com/nativelap/heartguard/viewmodel/record/RecordDraftViewModel.kt
index 45306f97..de1484db 100644
--- a/app/src/main/java/com/nativelap/heartguard/viewmodel/record/RecordDraftViewModel.kt
+++ b/app/src/main/java/com/nativelap/heartguard/viewmodel/record/RecordDraftViewModel.kt
@@ -270,7 +270,10 @@ class RecordDraftViewModel
* [submissionEffects](저장 화면의 이동)로 알린다.
* 저장은 화면이 아니라 viewModelScope에서 돌아, 화면을 벗어나거나 회전해도 요청이 끊기지 않는다.
* 진행 중이면 연타를 무시하고(같은 프레임의 두 번 입력도 [submitJob]으로 막는다), 요청이 취소되면 저장 전 상태로 되돌린다. */
- fun submit() {
+ fun submit(
+ defaultTemperature: Double? = null,
+ defaultHumidity: Double? = null,
+ ) {
if (submitJob?.isActive == true ||
mutableSubmissionState.value is RecordSubmissionState.Unknown ||
mutableSubmissionState.value is RecordSubmissionState.Success
@@ -311,6 +314,8 @@ class RecordDraftViewModel
owningGeneration = owningGeneration,
owningDraft = owningDraft,
hasPreviousAttempt = hasPreviousAttempt,
+ defaultTemperature = defaultTemperature,
+ defaultHumidity = defaultHumidity,
)
if (owningGeneration != sessionManager.getSnapshot().generation ||
owningDraft != draftGeneration
@@ -367,6 +372,8 @@ class RecordDraftViewModel
owningGeneration: Long,
owningDraft: Long,
hasPreviousAttempt: Boolean,
+ defaultTemperature: Double?,
+ defaultHumidity: Double?,
): RecordSubmitResult {
if (hasPreviousAttempt) {
when (val pending = getPendingRecordSubmissionsUseCase()) {
@@ -423,13 +430,13 @@ class RecordDraftViewModel
if (isManualTemperature) {
state.temperatureText.toDoubleOrNull()
} else {
- null
+ defaultTemperature
},
humidity =
if (isManualTemperature) {
state.humidityText.toDoubleOrNull()
} else {
- null
+ defaultHumidity
},
memo = state.memoFor(recordType),
restStartedAt = if (recordType == RecordType.REST) state.restStartedAt else null,
diff --git a/app/src/test/java/com/nativelap/heartguard/viewmodel/home/HomeViewModelParallelRefreshTest.kt b/app/src/test/java/com/nativelap/heartguard/viewmodel/home/HomeViewModelParallelRefreshTest.kt
index c302d4b0..19d7ce39 100644
--- a/app/src/test/java/com/nativelap/heartguard/viewmodel/home/HomeViewModelParallelRefreshTest.kt
+++ b/app/src/test/java/com/nativelap/heartguard/viewmodel/home/HomeViewModelParallelRefreshTest.kt
@@ -1,6 +1,8 @@
package com.nativelap.heartguard.viewmodel.home
import androidx.lifecycle.viewModelScope
+import com.nativelap.heartguard.core.location.CurrentCoordinates
+import com.nativelap.heartguard.core.location.CurrentLocationProvider
import com.nativelap.heartguard.core.network.ApiError
import com.nativelap.heartguard.core.network.ApiResult
import com.nativelap.heartguard.core.session.SessionManager
@@ -9,8 +11,12 @@ import com.nativelap.heartguard.core.session.TokenStorage
import com.nativelap.heartguard.domain.record.model.RecordHistoryEntry
import com.nativelap.heartguard.domain.record.repository.RecordHistoryRepository
import com.nativelap.heartguard.domain.record.usecase.GetRecordHistoryUseCase
+import com.nativelap.heartguard.domain.site.model.CurrentLocationWeather
+import com.nativelap.heartguard.domain.site.model.SkyStatus
import com.nativelap.heartguard.domain.site.model.TeamSiteOverview
+import com.nativelap.heartguard.domain.site.model.WeatherAvailability
import com.nativelap.heartguard.domain.site.repository.TeamSiteRepository
+import com.nativelap.heartguard.domain.site.usecase.GetCurrentLocationWeatherUseCase
import com.nativelap.heartguard.domain.site.usecase.GetTeamSiteOverviewUseCase
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
@@ -18,7 +24,11 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
@@ -348,11 +358,253 @@ class HomeViewModelParallelRefreshTest {
}
}
+ @Test
+ fun `available location weather becomes the shared home and record value`() =
+ runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ Dispatchers.setMain(dispatcher)
+ val testViewModel =
+ createHomeViewModel(
+ recordRepository = FakeRecordHistoryRepository { ApiResult.Success(emptyList()) },
+ siteRepository =
+ FakeTeamSiteRepository(
+ getOverview = {
+ ApiResult.Success(
+ siteOverview("team").copy(
+ currentTemperature = 22.0,
+ humidity = 44.0,
+ ),
+ )
+ },
+ getWeather = { _, _ -> ApiResult.Success(locationWeather(WeatherAvailability.AVAILABLE)) },
+ ),
+ dispatcher = dispatcher,
+ currentLocationProvider = { CurrentCoordinates(37.5665, 126.978) },
+ )
+ val viewModel = testViewModel.viewModel
+ val siteStatusCollection =
+ backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.siteStatus.collect() }
+
+ try {
+ viewModel.refresh()
+ runCurrent()
+ viewModel.loadCurrentLocationWeather()
+ runCurrent()
+
+ assertEquals("31", viewModel.siteStatus.value.temperature)
+ assertEquals(31.0, viewModel.siteStatus.value.recordTemperature)
+ assertEquals(55.0, viewModel.siteStatus.value.recordHumidity)
+ } finally {
+ siteStatusCollection.cancel()
+ advanceTimeBy(5_001L)
+ viewModel.viewModelScope.cancel()
+ Dispatchers.resetMain()
+ }
+ }
+
+ @Test
+ fun `clearing location weather after permission revoke restores team values and ignores in flight response`() =
+ runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ Dispatchers.setMain(dispatcher)
+ val lateWeatherResult = CompletableDeferred>()
+ var weatherCallCount = 0
+ val testViewModel =
+ createHomeViewModel(
+ recordRepository = FakeRecordHistoryRepository { ApiResult.Success(emptyList()) },
+ siteRepository =
+ FakeTeamSiteRepository(
+ getOverview = {
+ ApiResult.Success(
+ siteOverview("team").copy(
+ currentTemperature = 22.0,
+ humidity = 44.0,
+ ),
+ )
+ },
+ getWeather = { _, _ ->
+ weatherCallCount += 1
+ if (weatherCallCount == 1) {
+ ApiResult.Success(locationWeather(WeatherAvailability.AVAILABLE))
+ } else {
+ lateWeatherResult.await()
+ }
+ },
+ ),
+ dispatcher = dispatcher,
+ currentLocationProvider = { CurrentCoordinates(37.5665, 126.978) },
+ )
+ val viewModel = testViewModel.viewModel
+ val siteStatusCollection =
+ backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.siteStatus.collect() }
+
+ try {
+ viewModel.refresh()
+ runCurrent()
+ viewModel.loadCurrentLocationWeather()
+ runCurrent()
+ assertEquals(31.0, viewModel.siteStatus.value.recordTemperature)
+
+ viewModel.loadCurrentLocationWeather()
+ runCurrent()
+ assertEquals(2, weatherCallCount)
+
+ viewModel.clearCurrentLocationWeather()
+ lateWeatherResult.complete(ApiResult.Success(locationWeather(WeatherAvailability.CACHED)))
+ runCurrent()
+
+ assertEquals("22", viewModel.siteStatus.value.temperature)
+ assertEquals(22.0, viewModel.siteStatus.value.recordTemperature)
+ assertEquals(44.0, viewModel.siteStatus.value.recordHumidity)
+ } finally {
+ siteStatusCollection.cancel()
+ advanceTimeBy(5_001L)
+ viewModel.viewModelScope.cancel()
+ Dispatchers.resetMain()
+ }
+ }
+
+ @Test
+ fun `granted location request keeps team values on unavailable and failed responses`() =
+ runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ Dispatchers.setMain(dispatcher)
+ var locationCalls = 0
+ var weatherResult: ApiResult =
+ ApiResult.Success(locationWeather(WeatherAvailability.UNAVAILABLE))
+ val repository =
+ FakeTeamSiteRepository(
+ getOverview = {
+ ApiResult.Success(
+ siteOverview("team").copy(
+ currentTemperature = 22.0,
+ humidity = 44.0,
+ ),
+ )
+ },
+ getWeather = { latitude, longitude ->
+ assertEquals(37.5665, latitude)
+ assertEquals(126.978, longitude)
+ weatherResult
+ },
+ )
+ val testViewModel =
+ createHomeViewModel(
+ recordRepository = FakeRecordHistoryRepository { ApiResult.Success(emptyList()) },
+ siteRepository = repository,
+ dispatcher = dispatcher,
+ currentLocationProvider = {
+ locationCalls += 1
+ CurrentCoordinates(37.5665, 126.978)
+ },
+ )
+ val viewModel = testViewModel.viewModel
+ val siteStatusCollection =
+ backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.siteStatus.collect() }
+
+ try {
+ viewModel.refresh()
+ runCurrent()
+ viewModel.loadCurrentLocationWeather()
+ runCurrent()
+
+ assertEquals(1, locationCalls)
+ assertEquals("22", viewModel.siteStatus.value.temperature)
+ assertEquals(22.0, viewModel.siteStatus.value.recordTemperature)
+
+ weatherResult = ApiResult.Failure(ApiError.Network)
+ viewModel.loadCurrentLocationWeather()
+ runCurrent()
+
+ assertEquals(2, locationCalls)
+ assertEquals("22", viewModel.siteStatus.value.temperature)
+ assertEquals(44.0, viewModel.siteStatus.value.recordHumidity)
+ } finally {
+ siteStatusCollection.cancel()
+ advanceTimeBy(5_001L)
+ viewModel.viewModelScope.cancel()
+ Dispatchers.resetMain()
+ }
+ }
+
+ @Test
+ fun `location weather request ignores response after session generation changes`() =
+ runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ Dispatchers.setMain(dispatcher)
+ val weatherResult = CompletableDeferred>()
+ val testViewModel =
+ createHomeViewModel(
+ recordRepository = FakeRecordHistoryRepository { ApiResult.Success(emptyList()) },
+ siteRepository =
+ FakeTeamSiteRepository(
+ getOverview = { ApiResult.Success(siteOverview("team").copy(currentTemperature = 22.0)) },
+ getWeather = { _, _ -> weatherResult.await() },
+ ),
+ dispatcher = dispatcher,
+ currentLocationProvider = { CurrentCoordinates(37.5665, 126.978) },
+ )
+ val viewModel = testViewModel.viewModel
+ val siteStatusCollection =
+ backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.siteStatus.collect() }
+
+ try {
+ viewModel.refresh()
+ runCurrent()
+ viewModel.loadCurrentLocationWeather()
+ runCurrent()
+ testViewModel.sessionManager.onLoginSucceeded(SessionToken("replacement-session-token"))
+ weatherResult.complete(ApiResult.Success(locationWeather(WeatherAvailability.AVAILABLE)))
+ runCurrent()
+
+ assertEquals(22.0, viewModel.siteStatus.value.recordTemperature)
+ } finally {
+ siteStatusCollection.cancel()
+ advanceTimeBy(5_001L)
+ viewModel.viewModelScope.cancel()
+ Dispatchers.resetMain()
+ }
+ }
+
+ @Test
+ fun `duplicate location weather request while in flight shares one location lookup`() =
+ runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ Dispatchers.setMain(dispatcher)
+ val locationGate = CompletableDeferred()
+ var locationCalls = 0
+ val testViewModel =
+ createHomeViewModel(
+ recordRepository = FakeRecordHistoryRepository { ApiResult.Success(emptyList()) },
+ siteRepository = FakeTeamSiteRepository { ApiResult.Success(siteOverview("team")) },
+ dispatcher = dispatcher,
+ currentLocationProvider = {
+ locationCalls += 1
+ locationGate.await()
+ },
+ )
+ val viewModel = testViewModel.viewModel
+
+ try {
+ viewModel.loadCurrentLocationWeather()
+ viewModel.loadCurrentLocationWeather()
+ runCurrent()
+
+ assertEquals(1, locationCalls)
+ locationGate.complete(null)
+ runCurrent()
+ } finally {
+ viewModel.viewModelScope.cancel()
+ Dispatchers.resetMain()
+ }
+ }
+
private suspend fun createHomeViewModel(
recordRepository: RecordHistoryRepository,
siteRepository: TeamSiteRepository,
dispatcher: CoroutineDispatcher,
sessionManager: SessionManager? = null,
+ currentLocationProvider: suspend () -> CurrentCoordinates? = { null },
): TestHomeViewModel {
val activeSessionManager = sessionManager ?: createLoggedInSessionManager(dispatcher)
val viewModel =
@@ -361,6 +613,11 @@ class HomeViewModelParallelRefreshTest {
getRecordHistoryUseCase = GetRecordHistoryUseCase(recordRepository),
sessionManager = activeSessionManager,
clock = Clock.fixed(java.time.Instant.parse("2026-10-31T03:00:00Z"), ZoneId.of("Asia/Seoul")),
+ currentLocationProvider =
+ object : CurrentLocationProvider {
+ override suspend fun getCurrentLocation(): CurrentCoordinates? = currentLocationProvider()
+ },
+ getCurrentLocationWeatherUseCase = GetCurrentLocationWeatherUseCase(siteRepository),
)
return TestHomeViewModel(viewModel, activeSessionManager)
}
@@ -395,11 +652,31 @@ class HomeViewModelParallelRefreshTest {
)
private class FakeTeamSiteRepository(
+ private val getWeather: suspend (Double, Double) -> ApiResult = { _, _ ->
+ error("Weather is not used in this test")
+ },
private val getOverview: suspend () -> ApiResult,
) : TeamSiteRepository {
override suspend fun getTeamSiteOverview(): ApiResult = getOverview()
+
+ override suspend fun getCurrentLocationWeather(
+ latitude: Double,
+ longitude: Double,
+ ): ApiResult = getWeather(latitude, longitude)
}
+ private fun locationWeather(availability: WeatherAvailability) =
+ CurrentLocationWeather(
+ temperature = 31.0,
+ humidity = 55.0,
+ apparentTemperature = 30.8,
+ heatLevel = 0,
+ observedAt = "2026-11-06T15:00:00+09:00",
+ skyStatus = SkyStatus.CLEAR,
+ temperatureDelta = 2.1,
+ availability = availability,
+ )
+
private class FakeRecordHistoryRepository(
private val getRecords: suspend () -> ApiResult>,
) : RecordHistoryRepository {
diff --git a/app/src/test/java/com/nativelap/heartguard/viewmodel/home/SiteStatusUiModelTest.kt b/app/src/test/java/com/nativelap/heartguard/viewmodel/home/SiteStatusUiModelTest.kt
index 68e4f161..92926893 100644
--- a/app/src/test/java/com/nativelap/heartguard/viewmodel/home/SiteStatusUiModelTest.kt
+++ b/app/src/test/java/com/nativelap/heartguard/viewmodel/home/SiteStatusUiModelTest.kt
@@ -1,6 +1,9 @@
package com.nativelap.heartguard.viewmodel.home
+import com.nativelap.heartguard.domain.site.model.CurrentLocationWeather
+import com.nativelap.heartguard.domain.site.model.SkyStatus
import com.nativelap.heartguard.domain.site.model.TeamSiteOverview
+import com.nativelap.heartguard.domain.site.model.WeatherAvailability
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -56,18 +59,104 @@ class SiteStatusUiModelTest {
assertFalse(siteStatus.isTemperatureDeltaZero)
}
- private fun siteOverview(temperatureDelta: Double?) =
+ @Test
+ fun usesAvailableLocationWeatherForDisplayAndRecordDefaults() {
+ val siteStatus =
+ HomeUiState
+ .Success(siteOverview())
+ .toSiteStatusUiModel(locationWeather(WeatherAvailability.AVAILABLE))
+
+ assertEquals("31", siteStatus.temperature)
+ assertEquals("55", siteStatus.humidity)
+ assertEquals("30.8", siteStatus.apparentTemperature)
+ assertEquals(0, siteStatus.heatLevel)
+ assertEquals(SkyStatus.CLEAR, siteStatus.skyStatus)
+ assertEquals("+2.1", siteStatus.temperatureDelta)
+ assertEquals(31.0, siteStatus.recordTemperature)
+ assertEquals(55.0, siteStatus.recordHumidity)
+ }
+
+ @Test
+ fun usesCachedLocationWeather() {
+ val siteStatus =
+ HomeUiState
+ .Success(siteOverview())
+ .toSiteStatusUiModel(locationWeather(WeatherAvailability.CACHED))
+
+ assertEquals("31", siteStatus.temperature)
+ assertEquals(31.0, siteStatus.recordTemperature)
+ }
+
+ @Test
+ fun unavailableLocationWeatherKeepsTeamWeather() {
+ val siteStatus =
+ HomeUiState
+ .Success(siteOverview())
+ .toSiteStatusUiModel(locationWeather(WeatherAvailability.UNAVAILABLE))
+
+ assertEquals("22", siteStatus.temperature)
+ assertEquals("40", siteStatus.humidity)
+ assertEquals(22.0, siteStatus.recordTemperature)
+ assertEquals(40.0, siteStatus.recordHumidity)
+ }
+
+ @Test
+ fun unknownLocationWeatherKeepsTeamWeather() {
+ val siteStatus =
+ HomeUiState
+ .Success(siteOverview())
+ .toSiteStatusUiModel(locationWeather(WeatherAvailability.UNKNOWN))
+
+ assertEquals("22", siteStatus.temperature)
+ }
+
+ @Test
+ fun availableLocationNullMeasurementsStayNull() {
+ val siteStatus =
+ HomeUiState
+ .Success(siteOverview())
+ .toSiteStatusUiModel(
+ locationWeather(
+ availability = WeatherAvailability.AVAILABLE,
+ temperature = null,
+ humidity = null,
+ ),
+ )
+
+ assertNull(siteStatus.temperature)
+ assertNull(siteStatus.humidity)
+ assertNull(siteStatus.recordTemperature)
+ assertNull(siteStatus.recordHumidity)
+ }
+
+ private fun siteOverview(temperatureDelta: Double? = null) =
TeamSiteOverview(
teamName = null,
workplace = null,
siteName = null,
managerPhoneNumber = null,
- currentTemperature = null,
- humidity = null,
- apparentTemperature = null,
- heatLevel = null,
+ currentTemperature = 22.0,
+ humidity = 40.0,
+ apparentTemperature = 22.5,
+ heatLevel = 1,
checkTimes = emptyList(),
hasActiveEmergencyCall = false,
+ skyStatus = SkyStatus.CLOUDY,
temperatureDelta = temperatureDelta,
)
+
+ private fun locationWeather(
+ availability: WeatherAvailability,
+ temperature: Double? = 31.0,
+ humidity: Double? = 55.0,
+ ) = CurrentLocationWeather(
+ temperature = temperature,
+ humidity = humidity,
+ apparentTemperature = 30.8,
+ heatLevel = 0,
+ observedAt = "2026-11-06T15:00:00+09:00",
+ skyStatus = SkyStatus.CLEAR,
+ temperatureDelta = 2.1,
+ availability = availability,
+ )
}
diff --git a/data/src/main/java/com/nativelap/heartguard/data/site/dto/CurrentLocationWeatherDto.kt b/data/src/main/java/com/nativelap/heartguard/data/site/dto/CurrentLocationWeatherDto.kt
new file mode 100644
index 00000000..aebc4ec2
--- /dev/null
+++ b/data/src/main/java/com/nativelap/heartguard/data/site/dto/CurrentLocationWeatherDto.kt
@@ -0,0 +1,32 @@
+package com.nativelap.heartguard.data.site.dto
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class CurrentLocationWeatherRequestDto(
+ @SerialName("latitude")
+ val latitude: Double,
+ @SerialName("longitude")
+ val longitude: Double,
+)
+
+@Serializable
+data class CurrentLocationWeatherResponseDto(
+ @SerialName("temperature")
+ val temperature: Double? = null,
+ @SerialName("humidity")
+ val humidity: Double? = null,
+ @SerialName("apparentTemperature")
+ val apparentTemperature: Double? = null,
+ @SerialName("heatLevel")
+ val heatLevel: Int? = null,
+ @SerialName("observedAt")
+ val observedAt: String? = null,
+ @SerialName("skyStatus")
+ val skyStatus: String? = null,
+ @SerialName("temperatureDelta")
+ val temperatureDelta: Double? = null,
+ @SerialName("availability")
+ val availability: String? = null,
+)
diff --git a/data/src/main/java/com/nativelap/heartguard/data/site/mapper/CurrentLocationWeatherMapper.kt b/data/src/main/java/com/nativelap/heartguard/data/site/mapper/CurrentLocationWeatherMapper.kt
new file mode 100644
index 00000000..8722ec40
--- /dev/null
+++ b/data/src/main/java/com/nativelap/heartguard/data/site/mapper/CurrentLocationWeatherMapper.kt
@@ -0,0 +1,38 @@
+package com.nativelap.heartguard.data.site.mapper
+
+import com.nativelap.heartguard.data.site.dto.CurrentLocationWeatherResponseDto
+import com.nativelap.heartguard.domain.site.model.CurrentLocationWeather
+import com.nativelap.heartguard.domain.site.model.SkyStatus
+import com.nativelap.heartguard.domain.site.model.WeatherAvailability
+import java.util.Locale
+
+internal fun CurrentLocationWeatherResponseDto.toDomain(): CurrentLocationWeather =
+ CurrentLocationWeather(
+ temperature = temperature,
+ humidity = humidity,
+ apparentTemperature = apparentTemperature,
+ heatLevel = heatLevel,
+ observedAt = observedAt,
+ skyStatus = skyStatus.toSkyStatus(),
+ temperatureDelta = temperatureDelta,
+ availability = availability.toWeatherAvailability(),
+ )
+
+private fun String?.toSkyStatus(): SkyStatus? =
+ when (this?.uppercase(Locale.ROOT)) {
+ "CLEAR" -> SkyStatus.CLEAR
+ "PARTLY_CLOUDY" -> SkyStatus.PARTLY_CLOUDY
+ "CLOUDY" -> SkyStatus.CLOUDY
+ "RAIN" -> SkyStatus.RAIN
+ "SNOW" -> SkyStatus.SNOW
+ null, "" -> null
+ else -> SkyStatus.UNKNOWN
+ }
+
+private fun String?.toWeatherAvailability(): WeatherAvailability =
+ when (this?.uppercase(Locale.ROOT)) {
+ "AVAILABLE" -> WeatherAvailability.AVAILABLE
+ "CACHED" -> WeatherAvailability.CACHED
+ "UNAVAILABLE" -> WeatherAvailability.UNAVAILABLE
+ else -> WeatherAvailability.UNKNOWN
+ }
diff --git a/data/src/main/java/com/nativelap/heartguard/data/site/remote/TeamSiteApiService.kt b/data/src/main/java/com/nativelap/heartguard/data/site/remote/TeamSiteApiService.kt
index 8cef41ab..e8a1e16f 100644
--- a/data/src/main/java/com/nativelap/heartguard/data/site/remote/TeamSiteApiService.kt
+++ b/data/src/main/java/com/nativelap/heartguard/data/site/remote/TeamSiteApiService.kt
@@ -1,10 +1,19 @@
package com.nativelap.heartguard.data.site.remote
import com.nativelap.heartguard.core.network.ApiEnvelope
+import com.nativelap.heartguard.data.site.dto.CurrentLocationWeatherRequestDto
+import com.nativelap.heartguard.data.site.dto.CurrentLocationWeatherResponseDto
import com.nativelap.heartguard.data.site.dto.TeamSiteResponseDto
+import retrofit2.http.Body
import retrofit2.http.GET
+import retrofit2.http.POST
interface TeamSiteApiService {
@GET("api/v1/team")
suspend fun getTeamSite(): ApiEnvelope
+
+ @POST("api/v1/team/weather")
+ suspend fun getCurrentLocationWeather(
+ @Body request: CurrentLocationWeatherRequestDto,
+ ): ApiEnvelope
}
diff --git a/data/src/main/java/com/nativelap/heartguard/data/site/remote/TeamSiteRemoteDataSource.kt b/data/src/main/java/com/nativelap/heartguard/data/site/remote/TeamSiteRemoteDataSource.kt
index 4630f3ab..d7c03862 100644
--- a/data/src/main/java/com/nativelap/heartguard/data/site/remote/TeamSiteRemoteDataSource.kt
+++ b/data/src/main/java/com/nativelap/heartguard/data/site/remote/TeamSiteRemoteDataSource.kt
@@ -1,8 +1,14 @@
package com.nativelap.heartguard.data.site.remote
import com.nativelap.heartguard.core.network.ApiResult
+import com.nativelap.heartguard.data.site.dto.CurrentLocationWeatherRequestDto
+import com.nativelap.heartguard.data.site.dto.CurrentLocationWeatherResponseDto
import com.nativelap.heartguard.data.site.dto.TeamSiteResponseDto
interface TeamSiteRemoteDataSource {
suspend fun getTeamSite(): ApiResult
+
+ suspend fun getCurrentLocationWeather(
+ request: CurrentLocationWeatherRequestDto,
+ ): ApiResult
}
diff --git a/data/src/main/java/com/nativelap/heartguard/data/site/remote/TeamSiteRemoteDataSourceImpl.kt b/data/src/main/java/com/nativelap/heartguard/data/site/remote/TeamSiteRemoteDataSourceImpl.kt
index 7fab507c..edad6059 100644
--- a/data/src/main/java/com/nativelap/heartguard/data/site/remote/TeamSiteRemoteDataSourceImpl.kt
+++ b/data/src/main/java/com/nativelap/heartguard/data/site/remote/TeamSiteRemoteDataSourceImpl.kt
@@ -3,6 +3,8 @@ package com.nativelap.heartguard.data.site.remote
import com.nativelap.heartguard.core.network.ApiExecutor
import com.nativelap.heartguard.core.network.ApiResult
import com.nativelap.heartguard.core.network.requireSuccessData
+import com.nativelap.heartguard.data.site.dto.CurrentLocationWeatherRequestDto
+import com.nativelap.heartguard.data.site.dto.CurrentLocationWeatherResponseDto
import com.nativelap.heartguard.data.site.dto.TeamSiteResponseDto
import javax.inject.Inject
@@ -17,4 +19,12 @@ class TeamSiteRemoteDataSourceImpl
.execute {
teamSiteApiService.getTeamSite()
}.requireSuccessData()
+
+ override suspend fun getCurrentLocationWeather(
+ request: CurrentLocationWeatherRequestDto,
+ ): ApiResult =
+ apiExecutor
+ .execute {
+ teamSiteApiService.getCurrentLocationWeather(request)
+ }.requireSuccessData()
}
diff --git a/data/src/main/java/com/nativelap/heartguard/data/site/repository/TeamSiteRepositoryImpl.kt b/data/src/main/java/com/nativelap/heartguard/data/site/repository/TeamSiteRepositoryImpl.kt
index 60e1ef41..bb581e0c 100644
--- a/data/src/main/java/com/nativelap/heartguard/data/site/repository/TeamSiteRepositoryImpl.kt
+++ b/data/src/main/java/com/nativelap/heartguard/data/site/repository/TeamSiteRepositoryImpl.kt
@@ -2,8 +2,10 @@ package com.nativelap.heartguard.data.site.repository
import com.nativelap.heartguard.core.network.ApiResult
import com.nativelap.heartguard.core.network.map
+import com.nativelap.heartguard.data.site.dto.CurrentLocationWeatherRequestDto
import com.nativelap.heartguard.data.site.mapper.toDomain
import com.nativelap.heartguard.data.site.remote.TeamSiteRemoteDataSource
+import com.nativelap.heartguard.domain.site.model.CurrentLocationWeather
import com.nativelap.heartguard.domain.site.model.TeamSiteOverview
import com.nativelap.heartguard.domain.site.repository.TeamSiteRepository
import javax.inject.Inject
@@ -15,4 +17,16 @@ class TeamSiteRepositoryImpl
) : TeamSiteRepository {
override suspend fun getTeamSiteOverview(): ApiResult =
teamSiteRemoteDataSource.getTeamSite().map { it.toDomain() }
+
+ override suspend fun getCurrentLocationWeather(
+ latitude: Double,
+ longitude: Double,
+ ): ApiResult =
+ teamSiteRemoteDataSource
+ .getCurrentLocationWeather(
+ CurrentLocationWeatherRequestDto(
+ latitude = latitude,
+ longitude = longitude,
+ ),
+ ).map { response -> response.toDomain() }
}
diff --git a/data/src/test/java/com/nativelap/heartguard/data/site/mapper/CurrentLocationWeatherMapperTest.kt b/data/src/test/java/com/nativelap/heartguard/data/site/mapper/CurrentLocationWeatherMapperTest.kt
new file mode 100644
index 00000000..6d67b07e
--- /dev/null
+++ b/data/src/test/java/com/nativelap/heartguard/data/site/mapper/CurrentLocationWeatherMapperTest.kt
@@ -0,0 +1,80 @@
+package com.nativelap.heartguard.data.site.mapper
+
+import com.nativelap.heartguard.data.site.dto.CurrentLocationWeatherRequestDto
+import com.nativelap.heartguard.data.site.dto.CurrentLocationWeatherResponseDto
+import com.nativelap.heartguard.domain.site.model.SkyStatus
+import com.nativelap.heartguard.domain.site.model.WeatherAvailability
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.double
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+
+class CurrentLocationWeatherMapperTest {
+ @Test
+ fun requestEncodesCoordinatesAsJsonNumbers() {
+ val request =
+ CurrentLocationWeatherRequestDto(
+ latitude = 37.5665,
+ longitude = 126.978,
+ )
+
+ val requestJson = Json.parseToJsonElement(Json.encodeToString(request)).jsonObject
+
+ assertEquals(37.5665, requestJson.getValue("latitude").jsonPrimitive.double)
+ assertEquals(126.978, requestJson.getValue("longitude").jsonPrimitive.double)
+ assertFalse(requestJson.getValue("latitude").jsonPrimitive.isString)
+ assertFalse(requestJson.getValue("longitude").jsonPrimitive.isString)
+ }
+
+ @Test
+ fun mapsCachedWeatherAndPreservesNullableMeasurementValues() {
+ val weather =
+ CurrentLocationWeatherResponseDto(
+ temperature = 31.0,
+ humidity = null,
+ apparentTemperature = 30.8,
+ heatLevel = 0,
+ observedAt = "2026-11-06T15:00:00+09:00",
+ skyStatus = "CLEAR",
+ availability = "CACHED",
+ ).toDomain()
+
+ assertEquals(WeatherAvailability.CACHED, weather.availability)
+ assertEquals(31.0, weather.temperature)
+ assertNull(weather.humidity)
+ assertEquals(30.8, weather.apparentTemperature)
+ assertEquals(SkyStatus.CLEAR, weather.skyStatus)
+ }
+
+ @Test
+ fun mapsUnavailableAndUnrecognizedStatusSafely() {
+ val weather =
+ CurrentLocationWeatherResponseDto(
+ temperature = null,
+ humidity = null,
+ skyStatus = "FOG",
+ availability = "UNAVAILABLE",
+ ).toDomain()
+
+ assertEquals(WeatherAvailability.UNAVAILABLE, weather.availability)
+ assertNull(weather.temperature)
+ assertNull(weather.humidity)
+ assertEquals(SkyStatus.UNKNOWN, weather.skyStatus)
+ }
+
+ @Test
+ fun mapsUnknownAvailabilityToSafeUnknownState() {
+ val weather =
+ CurrentLocationWeatherResponseDto(
+ temperature = 32.0,
+ availability = "FUTURE_STATUS",
+ ).toDomain()
+
+ assertEquals(WeatherAvailability.UNKNOWN, weather.availability)
+ }
+}
diff --git a/docs/PRODUCT_BRIEF.md b/docs/PRODUCT_BRIEF.md
index bd13f205..bbf79e31 100644
--- a/docs/PRODUCT_BRIEF.md
+++ b/docs/PRODUCT_BRIEF.md
@@ -48,6 +48,7 @@
- 이렇게 한 이유: 서버가 `THERMOMETER`의 `photoKeys` 생략 또는 빈 배열을 허용하며, 사진 필수 UX를 제거해 온도 기록을 사용할 수 있게 하려는 요청 (출처: 사용자 제공 서버 계약 및 요청, 2026-11-06)
## 변경 이력
+- 2026-11-06: 홈 위치 기반 날씨 — coarse 위치 권한 동의 후 현재 좌표를 사용하고, 실패 시 기존 팀 날씨 또는 수동 입력으로 대체.
- 2026-11-05: 프로필 이미지 로딩 최적화 — 사용자 목표와 승인한 세션 캐시 절충을 반영.
- 2026-11-06: 프로필 사진 변경 시트 제목 간격 — 제목 위쪽 여백을 약 5.dp 늘림.
- 2026-11-06: 로그인·회원가입 인증 버튼 너비 — 입력 영역과 정렬하고 기존 반응형 여백을 유지.
@@ -60,3 +61,12 @@
- 가장 중요한 것: 기존 HeartGuard 로고를 더 선명하게 표시 (출처: 사용자 요청, 2026-11-05)
- 포기할 수 있는 것: 스플래시 배경·표시 시점·앱 진입 흐름은 변경하지 않음 (출처: 기존 구현 및 요청 범위, 2026-11-05)
- 이렇게 한 이유: 사용자가 제공한 로고 SVG를 적용해 선명도 개선을 요청함 (출처: 사용자 요청, 2026-11-05)
+
+## 홈 위치 기반 날씨
+
+- 마지막 확인: 2026-11-06
+- 핵심 사용자: 폭염 현장 작업자 (출처: `README.md` 개요, 2026-11-06 확인)
+- 왜 중요한지: 홈에 현재 위치 기준 기상청 날씨를 표시하고, 온도·사진 기록 기본값으로 사용하기 위함 (출처: 사용자 요청, 2026-11-06)
+- 가장 중요한 것: 동의 후 실제 좌표를 서버에 보내 서버가 제공하는 KMA 날씨를 홈과 기록에 반영 (출처: 사용자 요청, 2026-11-06)
+- 포기할 수 있는 것: coarse 위치 권한 거부·좌표 취득 실패·API 오류·사용 불가 응답이면 기존 팀 현장 날씨 또는 수동 입력을 사용 (출처: 사용자 요청 및 안전한 fallback 절충, 2026-11-06)
+- 이렇게 한 이유: 최신 날씨를 활용하되 위치를 쓸 수 없는 상황에서도 기존 기록 흐름을 유지하고 관리자 저장 현장 설정을 보존 (출처: 사용자 요청, 2026-11-06)
diff --git a/domain/src/main/java/com/nativelap/heartguard/domain/site/model/CurrentLocationWeather.kt b/domain/src/main/java/com/nativelap/heartguard/domain/site/model/CurrentLocationWeather.kt
new file mode 100644
index 00000000..3c6f5d0d
--- /dev/null
+++ b/domain/src/main/java/com/nativelap/heartguard/domain/site/model/CurrentLocationWeather.kt
@@ -0,0 +1,13 @@
+package com.nativelap.heartguard.domain.site.model
+
+/** 현재 좌표에 대해 서버가 반환한 기상 값이다. */
+data class CurrentLocationWeather(
+ val temperature: Double?,
+ val humidity: Double?,
+ val apparentTemperature: Double?,
+ val heatLevel: Int?,
+ val observedAt: String?,
+ val skyStatus: SkyStatus?,
+ val temperatureDelta: Double?,
+ val availability: WeatherAvailability,
+)
diff --git a/domain/src/main/java/com/nativelap/heartguard/domain/site/model/WeatherAvailability.kt b/domain/src/main/java/com/nativelap/heartguard/domain/site/model/WeatherAvailability.kt
new file mode 100644
index 00000000..b9618280
--- /dev/null
+++ b/domain/src/main/java/com/nativelap/heartguard/domain/site/model/WeatherAvailability.kt
@@ -0,0 +1,9 @@
+package com.nativelap.heartguard.domain.site.model
+
+/** 현재 위치 날씨 조회값의 서버 가용 상태다. */
+enum class WeatherAvailability {
+ AVAILABLE,
+ CACHED,
+ UNAVAILABLE,
+ UNKNOWN,
+}
diff --git a/domain/src/main/java/com/nativelap/heartguard/domain/site/repository/TeamSiteRepository.kt b/domain/src/main/java/com/nativelap/heartguard/domain/site/repository/TeamSiteRepository.kt
index 083e38f7..58ed3a4f 100644
--- a/domain/src/main/java/com/nativelap/heartguard/domain/site/repository/TeamSiteRepository.kt
+++ b/domain/src/main/java/com/nativelap/heartguard/domain/site/repository/TeamSiteRepository.kt
@@ -1,9 +1,15 @@
package com.nativelap.heartguard.domain.site.repository
import com.nativelap.heartguard.core.network.ApiResult
+import com.nativelap.heartguard.domain.site.model.CurrentLocationWeather
import com.nativelap.heartguard.domain.site.model.TeamSiteOverview
/** 팀 현장페이지(홈) 조회를 도메인 모델로 제공한다. DTO·Retrofit 타입은 노출하지 않는다. */
interface TeamSiteRepository {
suspend fun getTeamSiteOverview(): ApiResult
+
+ suspend fun getCurrentLocationWeather(
+ latitude: Double,
+ longitude: Double,
+ ): ApiResult
}
diff --git a/domain/src/main/java/com/nativelap/heartguard/domain/site/usecase/GetCurrentLocationWeatherUseCase.kt b/domain/src/main/java/com/nativelap/heartguard/domain/site/usecase/GetCurrentLocationWeatherUseCase.kt
new file mode 100644
index 00000000..eeb71a1a
--- /dev/null
+++ b/domain/src/main/java/com/nativelap/heartguard/domain/site/usecase/GetCurrentLocationWeatherUseCase.kt
@@ -0,0 +1,22 @@
+package com.nativelap.heartguard.domain.site.usecase
+
+import com.nativelap.heartguard.core.network.ApiResult
+import com.nativelap.heartguard.domain.site.model.CurrentLocationWeather
+import com.nativelap.heartguard.domain.site.repository.TeamSiteRepository
+import javax.inject.Inject
+
+/** 현재 위치 좌표의 기상 정보를 조회한다. */
+class GetCurrentLocationWeatherUseCase
+ @Inject
+ constructor(
+ private val teamSiteRepository: TeamSiteRepository,
+ ) {
+ suspend operator fun invoke(
+ latitude: Double,
+ longitude: Double,
+ ): ApiResult =
+ teamSiteRepository.getCurrentLocationWeather(
+ latitude = latitude,
+ longitude = longitude,
+ )
+ }