Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-feature
android:name="android.hardware.camera.any"
android:required="false" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.nativelap.heartguard.core.location

data class CurrentCoordinates(
val latitude: Double,
val longitude: Double,
)
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.nativelap.heartguard.core.location

interface CurrentLocationProvider {
suspend fun getCurrentLocation(): CurrentCoordinates?
}
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,7 @@ private fun HeartGuardMainNavDisplay(
entry<HeartGuardDestination.FieldPhoto> {
HeartGuardFieldPhotoRoute(
onBackClick = ::goBack,
homeViewModel = homeViewModel,
onNotificationClick = { backStack.add(HeartGuardDestination.TeamNotifications) },
recordDraftViewModel = recordDraftViewModel,
onSaveSuccess = ::goToSaveSuccess,
Expand All @@ -910,6 +911,7 @@ private fun HeartGuardMainNavDisplay(
entry<HeartGuardDestination.WorkPhoto> {
HeartGuardWorkPhotoRoute(
onBackClick = ::goBack,
homeViewModel = homeViewModel,
onNotificationClick = { backStack.add(HeartGuardDestination.TeamNotifications) },
recordDraftViewModel = recordDraftViewModel,
onSaveSuccess = ::goToSaveSuccess,
Expand All @@ -919,6 +921,7 @@ private fun HeartGuardMainNavDisplay(
entry<HeartGuardDestination.RestPhoto> {
HeartGuardRestPhotoRoute(
onBackClick = ::goBack,
homeViewModel = homeViewModel,
onNotificationClick = { backStack.add(HeartGuardDestination.TeamNotifications) },
recordDraftViewModel = recordDraftViewModel,
onSaveSuccess = ::goToSaveSuccess,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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 {}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ internal fun HeartGuardTemperatureRecordRoute(
recordDraftViewModel = recordDraftViewModel,
onSaveSuccess = onSaveSuccess,
onSaveFailure = onSaveFailure,
temperature = siteStatus.recordTemperature,
humidity = siteStatus.recordHumidity,
)

TemperatureRecordScreen(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -34,5 +38,10 @@ internal fun rememberRecordSubmitter(
}
}

return recordDraftViewModel::submit
return {
recordDraftViewModel.submit(
defaultTemperature = latestTemperature,
defaultHumidity = latestHumidity,
)
}
}
Loading
Loading