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, + ) + }