From 452dd8768a3b100bc4a030d4572e122889433b66 Mon Sep 17 00:00:00 2001 From: changs97 Date: Fri, 15 Aug 2025 16:42:44 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=ED=8F=AC=EA=B7=B8=EB=9D=BC?= =?UTF-8?q?=EC=9A=B4=EB=93=9C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- feature/walk/build.gradle.kts | 6 + feature/walk/src/main/AndroidManifest.xml | 14 + .../walk/navigation/WalkNavigation.kt | 2 +- .../runcombi/walk/screen/WalkMainScreen.kt | 21 +- .../walk/screen/WalkTrackingScreen.kt | 56 +-- .../walk/service/WalkTrackingDataManager.kt | 95 +++++ .../walk/service/WalkTrackingService.kt | 355 ++++++++++++++++++ .../walk/service/WalkTrackingServiceHelper.kt | 87 +++++ .../walk/viewmodel/WalkTrackingViewModel.kt | 168 +++++---- gradle/libs.versions.toml | 2 + 10 files changed, 678 insertions(+), 128 deletions(-) create mode 100644 feature/walk/src/main/java/com/combo/runcombi/walk/service/WalkTrackingDataManager.kt create mode 100644 feature/walk/src/main/java/com/combo/runcombi/walk/service/WalkTrackingService.kt create mode 100644 feature/walk/src/main/java/com/combo/runcombi/walk/service/WalkTrackingServiceHelper.kt diff --git a/feature/walk/build.gradle.kts b/feature/walk/build.gradle.kts index 3aa530f..8cce67c 100644 --- a/feature/walk/build.gradle.kts +++ b/feature/walk/build.gradle.kts @@ -23,4 +23,10 @@ dependencies { implementation(libs.lottie.compose) implementation(libs.androidx.graphics.shapes) + implementation(libs.gson) + + // Core 모듈 의존성 + implementation(project(":core:data:common")) + implementation(project(":core:domain:walk")) + implementation(project(":core:data:walk")) } \ No newline at end of file diff --git a/feature/walk/src/main/AndroidManifest.xml b/feature/walk/src/main/AndroidManifest.xml index a5918e6..a52a3a6 100644 --- a/feature/walk/src/main/AndroidManifest.xml +++ b/feature/walk/src/main/AndroidManifest.xml @@ -1,4 +1,18 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature/walk/src/main/java/com/combo/runcombi/walk/navigation/WalkNavigation.kt b/feature/walk/src/main/java/com/combo/runcombi/walk/navigation/WalkNavigation.kt index cd9a82e..8678e52 100644 --- a/feature/walk/src/main/java/com/combo/runcombi/walk/navigation/WalkNavigation.kt +++ b/feature/walk/src/main/java/com/combo/runcombi/walk/navigation/WalkNavigation.kt @@ -1,5 +1,6 @@ package com.combo.runcombi.walk.navigation +import WalkMainScreen import androidx.compose.runtime.remember import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController @@ -10,7 +11,6 @@ import androidx.navigation.compose.navigation import com.combo.runcombi.core.navigation.model.MainTabDataModel import com.combo.runcombi.core.navigation.model.RouteModel import com.combo.runcombi.walk.screen.WalkCountdownScreen -import com.combo.runcombi.walk.screen.WalkMainScreen import com.combo.runcombi.walk.screen.WalkReadyScreen import com.combo.runcombi.walk.screen.WalkResultScreen import com.combo.runcombi.walk.screen.WalkTrackingScreen diff --git a/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkMainScreen.kt b/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkMainScreen.kt index 7b2c710..dd99baf 100644 --- a/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkMainScreen.kt +++ b/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkMainScreen.kt @@ -1,9 +1,8 @@ -package com.combo.runcombi.walk.screen - import android.Manifest import android.annotation.SuppressLint import android.content.Intent import android.content.res.Configuration +import android.os.Build import android.provider.Settings import android.widget.Toast import androidx.compose.foundation.background @@ -91,6 +90,8 @@ fun WalkMainScreen( val cameraPositionState = rememberCameraPositionState() val uiState by walkMainViewModel.uiState.collectAsStateWithLifecycle() val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) + val notificationPermissionState = + rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) var showPermissionSettingSheet by remember { mutableStateOf(false) } val analyticsHelper = walkMainViewModel.analyticsHelper @@ -100,9 +101,14 @@ fun WalkMainScreen( if (!locationPermissionState.status.isGranted) { locationPermissionState.launchPermissionRequest() } - // 화면 진입 시 사용자 정보 갱신 + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && notificationPermissionState != null && !notificationPermissionState.status.isGranted) { + notificationPermissionState.launchPermissionRequest() + } + walkMainViewModel.fetchUserAndPets() } + LaunchedEffect(locationPermissionState.status.isGranted) { if (locationPermissionState.status.isGranted) { val myLocation = LocationUtil.getCurrentLocation(context) @@ -163,13 +169,20 @@ fun WalkMainScreen( isLocationPermissionGranted = locationPermissionState.status.isGranted, onPetClick = { walkMainViewModel.togglePetSelect(it) }, onStartWalk = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !notificationPermissionState.status.isGranted) { + notificationPermissionState.launchPermissionRequest() + } + if (!locationPermissionState.status.isGranted) { if (locationPermissionState.status.shouldShowRationale) { locationPermissionState.launchPermissionRequest() } else { walkMainViewModel.onStartWalkClicked(false) } - } else if (walkMainViewModel.checkWithInitWalkData()) { + return@WalkMainContent + } + + if (walkMainViewModel.checkWithInitWalkData()) { onStartWalk() } else { Toast.makeText(context, "함께 운동할 콤비를 선택해주세요.", Toast.LENGTH_SHORT).show() diff --git a/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkTrackingScreen.kt b/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkTrackingScreen.kt index da04866..ba1fb7f 100644 --- a/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkTrackingScreen.kt +++ b/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkTrackingScreen.kt @@ -2,7 +2,6 @@ package com.combo.runcombi.walk.screen import android.annotation.SuppressLint import android.content.res.Configuration -import android.os.Looper import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -22,7 +21,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -72,12 +70,6 @@ import com.combo.runcombi.walk.model.WalkUiState import com.combo.runcombi.walk.model.getBottomSheetContent import com.combo.runcombi.walk.viewmodel.WalkMainViewModel import com.combo.runcombi.walk.viewmodel.WalkTrackingViewModel -import com.google.android.gms.location.LocationCallback -import com.google.android.gms.location.LocationRequest -import com.google.android.gms.location.LocationResult -import com.google.android.gms.location.LocationServices -import com.google.android.gms.location.Priority -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest @SuppressLint("MissingPermission") @@ -92,35 +84,22 @@ fun WalkTrackingScreen( val analyticsHelper = walkRecordViewModel.analyticsHelper val uiState by walkRecordViewModel.uiState.collectAsStateWithLifecycle() val isPaused = uiState.isPaused - val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) } val isInitialized = rememberSaveable { mutableStateOf(false) } - val showSheet = remember { mutableStateOf(BottomSheetType.NONE) } - val locationCallback = remember { - object : LocationCallback() { - override fun onLocationResult(result: LocationResult) { - result.lastLocation?.let { location -> - walkRecordViewModel.addPathPointFromService( - location.latitude, location.longitude, location.accuracy, location.time - ) - } - } - } - } - LaunchedEffect(isInitialized.value) { if (!isInitialized.value) { analyticsHelper.logScreenView("WalkTrackingScreen") - walkMainViewModel.startRun() - val member = walkMainViewModel.walkData.value.member val exerciseType = walkMainViewModel.walkData.value.exerciseType val selectedPetList = walkMainViewModel.walkData.value.petList + if (member != null) { walkRecordViewModel.initWalkData(exerciseType, member, selectedPetList) + + walkMainViewModel.startRun() } isInitialized.value = true } @@ -132,27 +111,6 @@ fun WalkTrackingScreen( } } - DisposableEffect(isPaused) { - if (!isPaused) { - val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 2000) - .setMinUpdateIntervalMillis(1000).build() - - fusedLocationClient.requestLocationUpdates( - request, locationCallback, Looper.getMainLooper() - ) - } - onDispose { - fusedLocationClient.removeLocationUpdates(locationCallback) - } - } - - LaunchedEffect(isPaused) { - while (!isPaused) { - walkRecordViewModel.updateTime(uiState.time + 1) - delay(1000) - } - } - WalkTrackingContent( uiState = uiState, onPauseToggle = walkRecordViewModel::togglePause, @@ -170,7 +128,6 @@ fun WalkTrackingScreen( onAccept = { when (showSheet.value) { BottomSheetType.FINISH -> { - // 산책 완료 이벤트 로깅 val duration = FormatUtils.formatTime(uiState.time) val distance = String.format("%.2f", uiState.distance / 1000.0) analyticsHelper.logWalkCompleted(duration, "${distance}km") @@ -182,10 +139,15 @@ fun WalkTrackingScreen( member = uiState.walkMemberUiModel, petList = uiState.walkPetUIModelList ?: emptyList() ) + + walkRecordViewModel.stopTracking() onFinish() } - BottomSheetType.CANCEL -> onBack() + BottomSheetType.CANCEL -> { + walkRecordViewModel.stopTracking() + onBack() + } else -> Unit } showSheet.value = BottomSheetType.NONE diff --git a/feature/walk/src/main/java/com/combo/runcombi/walk/service/WalkTrackingDataManager.kt b/feature/walk/src/main/java/com/combo/runcombi/walk/service/WalkTrackingDataManager.kt new file mode 100644 index 0000000..abdc736 --- /dev/null +++ b/feature/walk/src/main/java/com/combo/runcombi/walk/service/WalkTrackingDataManager.kt @@ -0,0 +1,95 @@ +package com.combo.runcombi.walk.service + +import android.util.Log +import com.combo.runcombi.walk.model.LocationPoint +import com.combo.runcombi.walk.model.WalkMemberUiModel +import com.combo.runcombi.walk.model.WalkPetUIModel +import com.google.android.gms.maps.model.LatLng +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WalkTrackingDataManager @Inject constructor() { + companion object { + private const val TAG = "WalkTrackingDataManager" + } + + private val _trackingData = MutableStateFlow(TrackingData()) + val trackingData: StateFlow = _trackingData.asStateFlow() + + fun updateLocationData( + pathPoints: List, + distance: Double, + time: Int + ) { + Log.d(TAG, "updateLocationData: 경로점=${pathPoints.size}, 거리=${distance}m, 시간=${time}초") + _trackingData.update { it.copy( + pathPoints = pathPoints, + distance = distance, + time = time + ) } + } + + fun updateMemberData(member: WalkMemberUiModel?) { + Log.d(TAG, "updateMemberData: 멤버 업데이트 - $member") + _trackingData.update { it.copy(member = member) } + } + + fun updatePetListData(petList: List?) { + Log.d(TAG, "updatePetListData: 반려동물 리스트 업데이트 - $petList") + _trackingData.update { it.copy(petList = petList) } + } + + fun updateExerciseType(exerciseType: String) { + Log.d(TAG, "updateExerciseType: 운동 타입 업데이트 - $exerciseType") + _trackingData.update { it.copy(exerciseType = exerciseType) } + } + + fun updateInitialData( + exerciseType: String, + member: WalkMemberUiModel, + petList: List + ) { + Log.d(TAG, "updateInitialData: 초기 데이터 설정 - 타입:$exerciseType, 멤버:${member}, 반려동물:${petList}") + _trackingData.update { it.copy( + exerciseType = exerciseType, + member = member, + petList = petList, + time = 0, + distance = 0.0, + pathPoints = emptyList(), + isPaused = false, + isTracking = true + ) } + } + + fun updatePauseState(isPaused: Boolean) { + Log.d(TAG, "updatePauseState: 일시정지 상태 업데이트 - $isPaused") + _trackingData.update { it.copy(isPaused = isPaused) } + } + + fun updateTrackingState(isTracking: Boolean) { + Log.d(TAG, "updateTrackingState: 추적 상태 업데이트 - $isTracking") + _trackingData.update { it.copy(isTracking = isTracking) } + } + + fun resetData() { + Log.d(TAG, "resetData: 추적 데이터 초기화") + _trackingData.value = TrackingData() + } + + data class TrackingData( + val exerciseType: String = "", + val time: Int = 0, + val distance: Double = 0.0, + val pathPoints: List = emptyList(), + val member: WalkMemberUiModel? = null, + val petList: List? = null, + val isPaused: Boolean = false, + val isTracking: Boolean = false + ) +} diff --git a/feature/walk/src/main/java/com/combo/runcombi/walk/service/WalkTrackingService.kt b/feature/walk/src/main/java/com/combo/runcombi/walk/service/WalkTrackingService.kt new file mode 100644 index 0000000..8899891 --- /dev/null +++ b/feature/walk/src/main/java/com/combo/runcombi/walk/service/WalkTrackingService.kt @@ -0,0 +1,355 @@ +package com.combo.runcombi.walk.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.location.Location +import android.os.Build +import android.os.IBinder +import android.os.Looper +import android.util.Log +import androidx.core.app.NotificationCompat +import com.combo.runcombi.walk.model.LocationPoint +import com.combo.runcombi.walk.usecase.CalculateMemberCalorieUseCase +import com.combo.runcombi.walk.usecase.CalculatePetCalorieUseCase +import com.combo.runcombi.walk.usecase.UpdateWalkRecordUseCase +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.math.BigDecimal +import java.math.RoundingMode +import javax.inject.Inject +import kotlin.math.roundToInt + +@AndroidEntryPoint +class WalkTrackingService : Service() { + + companion object { + private const val NOTIFICATION_ID = 1001 + private const val CHANNEL_ID = "walk_tracking_channel" + private const val CHANNEL_NAME = "운동 추적" + private const val CHANNEL_DESCRIPTION = "운동 중 위치 추적을 위한 알림" + + const val ACTION_START_TRACKING = "com.combo.runcombi.START_TRACKING" + const val ACTION_STOP_TRACKING = "com.combo.runcombi.STOP_TRACKING" + const val ACTION_PAUSE_TRACKING = "com.combo.runcombi.PAUSE_TRACKING" + const val ACTION_RESUME_TRACKING = "com.combo.runcombi.RESUME_TRACKING" + + const val EXTRA_EXERCISE_TYPE = "exercise_type" + + private const val TAG = "WalkTrackingService" + } + + @Inject + lateinit var updateWalkRecordUseCase: UpdateWalkRecordUseCase + + @Inject + lateinit var calculateMemberCalorieUseCase: CalculateMemberCalorieUseCase + + @Inject + lateinit var calculatePetCalorieUseCase: CalculatePetCalorieUseCase + + @Inject + lateinit var dataManager: WalkTrackingDataManager + + private lateinit var fusedLocationClient: FusedLocationProviderClient + private lateinit var locationCallback: LocationCallback + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private var timeUpdateJob: Job? = null + + private var lastPoint: LocationPoint? = null + private var speedList: List = emptyList() + + override fun onCreate() { + super.onCreate() + Log.d(TAG, "onCreate: 서비스 생성 시작") + fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) + createNotificationChannel() + setupLocationCallback() + Log.d(TAG, "onCreate: 서비스 생성 완료") + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.d(TAG, "onStartCommand: action=${intent?.action}, startId=$startId") + + when (intent?.action) { + ACTION_START_TRACKING -> { + val exerciseType = + intent.getStringExtra(EXTRA_EXERCISE_TYPE) ?: return START_NOT_STICKY + Log.d(TAG, "onStartCommand: 운동 추적 시작 - exerciseType=$exerciseType") + startForegroundTracking(exerciseType) + } + + ACTION_STOP_TRACKING -> { + Log.d(TAG, "onStartCommand: 운동 추적 중지") + stopTracking() + } + + ACTION_PAUSE_TRACKING -> { + Log.d(TAG, "onStartCommand: 운동 추적 일시정지") + pauseTracking() + } + + ACTION_RESUME_TRACKING -> { + Log.d(TAG, "onStartCommand: 운동 추적 재개") + resumeTracking() + } + + else -> { + Log.w(TAG, "onStartCommand: 알 수 없는 action=${intent?.action}") + } + } + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun startForegroundTracking(exerciseType: String) { + Log.d(TAG, "startForegroundTracking: 시작 - exerciseType=$exerciseType") + try { + dataManager.updateExerciseType(exerciseType) + dataManager.updateTrackingState(true) + Log.d(TAG, "startForegroundTracking: 데이터 매니저 업데이트 완료") + + startLocationUpdates() + startTimeUpdates() + Log.d(TAG, "startForegroundTracking: 위치 및 시간 업데이트 시작") + + startForeground(NOTIFICATION_ID, createNotification()) + Log.d(TAG, "startForegroundTracking: Foreground 서비스 시작 완료") + } catch (e: Exception) { + Log.e(TAG, "startForegroundTracking: 오류 발생", e) + stopSelf() + } + } + + private fun stopTracking() { + dataManager.updateTrackingState(false) + stopLocationUpdates() + stopTimeUpdates() + stopForeground(true) + stopSelf() + } + + private fun pauseTracking() { + dataManager.updatePauseState(true) + stopLocationUpdates() + stopTimeUpdates() + } + + private fun resumeTracking() { + dataManager.updatePauseState(false) + startLocationUpdates() + startTimeUpdates() + } + + private fun startLocationUpdates() { + Log.d(TAG, "startLocationUpdates: 위치 업데이트 시작") + val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000) + .setWaitForAccurateLocation(false) + .setMinUpdateIntervalMillis(500) + .setMaxUpdateDelayMillis(1000) + .build() + + try { + fusedLocationClient.requestLocationUpdates( + locationRequest, + locationCallback, + Looper.getMainLooper() + ) + Log.d(TAG, "startLocationUpdates: 위치 업데이트 요청 성공") + } catch (e: SecurityException) { + Log.e(TAG, "startLocationUpdates: 위치 권한 없음", e) + } catch (e: Exception) { + Log.e(TAG, "startLocationUpdates: 위치 업데이트 요청 실패", e) + } + } + + private fun stopLocationUpdates() { + fusedLocationClient.removeLocationUpdates(locationCallback) + } + + private fun setupLocationCallback() { + locationCallback = object : LocationCallback() { + override fun onLocationResult(locationResult: LocationResult) { + locationResult.lastLocation?.let { location -> + updateLocationData(location) + } + } + } + } + + private fun updateLocationData(location: Location) { + val newPoint = LocationPoint( + location.latitude, + location.longitude, + location.time, + location.accuracy + ) + + Log.d( + TAG, + "updateLocationData: 새로운 위치 - lat=${location.latitude}, lng=${location.longitude}, accuracy=${location.accuracy}" + ) + + val currentPathPoints = dataManager.trackingData.value.pathPoints + val newPathPoints = currentPathPoints + com.google.android.gms.maps.model.LatLng( + location.latitude, + location.longitude + ) + + when (val result = updateWalkRecordUseCase(lastPoint, newPoint, speedList)) { + is com.combo.runcombi.common.DomainResult.Success -> { + val data = result.data + val currentDistance = dataManager.trackingData.value.distance + val newDistance = currentDistance + data.distance + + Log.d( + TAG, + "updateLocationData: 거리 업데이트 - 기존: ${currentDistance}m, 추가: ${data.distance}m, 새로운: ${newDistance}m" + ) + + updateCalories(newDistance) + + dataManager.updateLocationData( + newPathPoints, + newDistance, + dataManager.trackingData.value.time + ) + + lastPoint = newPoint + speedList = + (speedList + (if (lastPoint != null) data.distance / ((newPoint.timestamp - (lastPoint?.timestamp + ?: newPoint.timestamp)).coerceAtLeast(1000L) / 1000.0) else 0.0)).takeLast( + 100 + ) + + Log.d( + TAG, + "updateLocationData: 성공적으로 업데이트됨 - 총 경로점: ${newPathPoints.size}, 속도 리스트 크기: ${speedList.size}" + ) + } + + is com.combo.runcombi.common.DomainResult.Error, is com.combo.runcombi.common.DomainResult.Exception -> { + Log.w(TAG, "updateLocationData: UseCase 실행 실패 - ${result.javaClass.simpleName}") + + updateCalories(dataManager.trackingData.value.distance) + + dataManager.updateLocationData( + newPathPoints, + dataManager.trackingData.value.distance, + dataManager.trackingData.value.time + ) + lastPoint = newPoint + } + } + } + + private fun updateCalories(distanceMeters: Double) { + val distanceKm = distanceMeters / 1000.0 + val rounded = BigDecimal(distanceKm).setScale(2, RoundingMode.HALF_UP).toDouble() + + val trackingData = dataManager.trackingData.value + val exerciseType = try { + com.combo.runcombi.walk.model.ExerciseType.valueOf(trackingData.exerciseType) + } catch (e: IllegalArgumentException) { + com.combo.runcombi.walk.model.ExerciseType.WALKING + } + + // 멤버 칼로리 계산 + val memberUiModel = trackingData.member?.let { member -> + val memberCalorie = calculateMemberCalorieUseCase( + exerciseType, + member.member.gender.name, + member.member.weight.toDouble(), + rounded + ).roundToInt() + member.copy(calorie = memberCalorie) + } + + val petUiModelList = trackingData.petList?.map { petUiModel -> + val pet = petUiModel.pet + val petCalorie = calculatePetCalorieUseCase( + pet.weight, + rounded, + pet.runStyle.activityFactor + ).roundToInt() + petUiModel.copy(calorie = petCalorie) + } + + dataManager.updateMemberData(memberUiModel) + dataManager.updatePetListData(petUiModelList) + + Log.d( + TAG, + "updateCalories: 칼로리 업데이트 완료 - 거리: ${rounded}km, 멤버칼로리: ${memberUiModel?.calorie}, 펫칼로리: ${petUiModelList?.map { it.calorie }}" + ) + } + + private fun startTimeUpdates() { + timeUpdateJob = serviceScope.launch { + while (isActive) { + delay(1000) + if (!dataManager.trackingData.value.isPaused) { + val currentTime = dataManager.trackingData.value.time + dataManager.updateLocationData( + dataManager.trackingData.value.pathPoints, + dataManager.trackingData.value.distance, + currentTime + 1 + ) + } + } + } + } + + private fun stopTimeUpdates() { + timeUpdateJob?.cancel() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW + ).apply { + description = CHANNEL_DESCRIPTION + } + + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + } + + private fun createNotification(): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("운동 기록 중") + .setSmallIcon(com.combo.runcombi.core.designsystem.R.drawable.ic_walk_selected) + .setOngoing(true) + .build() + } + + override fun onDestroy() { + super.onDestroy() + serviceScope.cancel() + stopLocationUpdates() + stopTimeUpdates() + } +} diff --git a/feature/walk/src/main/java/com/combo/runcombi/walk/service/WalkTrackingServiceHelper.kt b/feature/walk/src/main/java/com/combo/runcombi/walk/service/WalkTrackingServiceHelper.kt new file mode 100644 index 0000000..5e814aa --- /dev/null +++ b/feature/walk/src/main/java/com/combo/runcombi/walk/service/WalkTrackingServiceHelper.kt @@ -0,0 +1,87 @@ +package com.combo.runcombi.walk.service + +import android.app.ActivityManager +import android.content.Context +import android.content.Intent +import android.util.Log +import com.combo.runcombi.walk.model.ExerciseType +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WalkTrackingServiceHelper @Inject constructor( + @ApplicationContext private val context: Context +) { + companion object { + private const val TAG = "WalkTrackingServiceHelper" + } + + fun startTracking(exerciseType: ExerciseType) { + Log.d(TAG, "startTracking: 운동 추적 시작 - exerciseType=${exerciseType.name}") + + val intent = Intent(context, WalkTrackingService::class.java).apply { + action = WalkTrackingService.ACTION_START_TRACKING + putExtra(WalkTrackingService.EXTRA_EXERCISE_TYPE, exerciseType.name) + } + + try { + context.startForegroundService(intent) + Log.d(TAG, "startTracking: Foreground 서비스 시작 요청 성공") + } catch (e: Exception) { + Log.e(TAG, "startTracking: Foreground 서비스 시작 실패", e) + } + } + + fun stopTracking() { + Log.d(TAG, "stopTracking: 운동 추적 중지") + val intent = Intent(context, WalkTrackingService::class.java).apply { + action = WalkTrackingService.ACTION_STOP_TRACKING + } + try { + context.startService(intent) + Log.d(TAG, "stopTracking: 서비스 중지 요청 성공") + } catch (e: Exception) { + Log.e(TAG, "stopTracking: 서비스 중지 요청 실패", e) + } + } + + fun pauseTracking() { + Log.d(TAG, "pauseTracking: 운동 추적 일시정지") + val intent = Intent(context, WalkTrackingService::class.java).apply { + action = WalkTrackingService.ACTION_PAUSE_TRACKING + } + try { + context.startService(intent) + Log.d(TAG, "pauseTracking: 서비스 일시정지 요청 성공") + } catch (e: Exception) { + Log.e(TAG, "pauseTracking: 서비스 일시정지 요청 실패", e) + } + } + + fun resumeTracking() { + Log.d(TAG, "resumeTracking: 운동 추적 재개") + val intent = Intent(context, WalkTrackingService::class.java).apply { + action = WalkTrackingService.ACTION_RESUME_TRACKING + } + try { + context.startService(intent) + Log.d(TAG, "resumeTracking: 서비스 재개 요청 성공") + } catch (e: Exception) { + Log.e(TAG, "resumeTracking: 서비스 재개 요청 실패", e) + } + } + + fun isTracking(): Boolean { + Log.d(TAG, "isTracking: 서비스 상태 확인 중") + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val runningServices = activityManager.getRunningServices(Integer.MAX_VALUE) + + val isRunning = runningServices.any { service -> + service.service.className == WalkTrackingService::class.java.name + } + + Log.d(TAG, "isTracking: 서비스 실행 상태 = $isRunning, 총 실행 중인 서비스 수 = ${runningServices.size}") + return isRunning + } +} diff --git a/feature/walk/src/main/java/com/combo/runcombi/walk/viewmodel/WalkTrackingViewModel.kt b/feature/walk/src/main/java/com/combo/runcombi/walk/viewmodel/WalkTrackingViewModel.kt index a7bfa48..640c5c5 100644 --- a/feature/walk/src/main/java/com/combo/runcombi/walk/viewmodel/WalkTrackingViewModel.kt +++ b/feature/walk/src/main/java/com/combo/runcombi/walk/viewmodel/WalkTrackingViewModel.kt @@ -3,19 +3,16 @@ package com.combo.runcombi.walk.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.combo.runcombi.analytics.AnalyticsHelper -import com.combo.runcombi.common.DomainResult import com.combo.runcombi.walk.model.BottomSheetType import com.combo.runcombi.walk.model.ExerciseType -import com.combo.runcombi.walk.model.LocationPoint import com.combo.runcombi.walk.model.WalkMemberUiModel import com.combo.runcombi.walk.model.WalkPetUIModel import com.combo.runcombi.walk.model.WalkTrackingEvent import com.combo.runcombi.walk.model.WalkUiState +import com.combo.runcombi.walk.service.WalkTrackingDataManager +import com.combo.runcombi.walk.service.WalkTrackingServiceHelper import com.combo.runcombi.walk.usecase.CalculateMemberCalorieUseCase import com.combo.runcombi.walk.usecase.CalculatePetCalorieUseCase -import com.combo.runcombi.walk.usecase.EndRunUseCase -import com.combo.runcombi.walk.usecase.UpdateWalkRecordUseCase -import com.google.android.gms.maps.model.LatLng import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -32,9 +29,10 @@ import kotlin.math.roundToInt @HiltViewModel class WalkTrackingViewModel @Inject constructor( - private val updateWalkRecordUseCase: UpdateWalkRecordUseCase, private val calculatePetCalorieUseCase: CalculatePetCalorieUseCase, private val calculateMemberCalorieUseCase: CalculateMemberCalorieUseCase, + private val dataManager: WalkTrackingDataManager, + private val serviceHelper: WalkTrackingServiceHelper, val analyticsHelper: AnalyticsHelper, ) : ViewModel() { @@ -44,77 +42,63 @@ class WalkTrackingViewModel @Inject constructor( private val _eventFlow = MutableSharedFlow() val eventFlow: SharedFlow = _eventFlow.asSharedFlow() - private var lastPoint: LocationPoint? = null - private var speedList: List = emptyList() - - fun addPathPointFromService( - lat: Double, - lng: Double, - accuracy: Float, - timestamp: Long = System.currentTimeMillis(), - ) { - val newPoint = LocationPoint(lat, lng, timestamp, accuracy) - when (val result = updateWalkRecordUseCase(lastPoint, newPoint, speedList)) { - is DomainResult.Success -> { - val data = result.data - val newDistance = _uiState.value.distance + data.distance - val km = newDistance / 1000.0 - val rounded = BigDecimal(km).setScale(2, RoundingMode.HALF_UP).toDouble() - - val exerciseType = _uiState.value.exerciseType - - val memberUiModel = _uiState.value.walkMemberUiModel - val memberCalorie = memberUiModel?.let { - val member = it.member - calculateMemberCalorieUseCase( - exerciseType, - member.gender.name, - member.weight.toDouble(), - rounded - ).roundToInt() - } ?: 0 - - val newPetUiModelList = _uiState.value.walkPetUIModelList?.map { petUiModel -> - val pet = petUiModel.pet - val petCalorie = calculatePetCalorieUseCase( - pet.weight, - rounded, - pet.runStyle.activityFactor - ).roundToInt() - petUiModel.copy(calorie = petCalorie) - } - - _uiState.update { state -> - state.copy( - pathPoints = state.pathPoints + LatLng(lat, lng), - distance = newDistance, - walkMemberUiModel = memberUiModel?.copy(calorie = memberCalorie), - walkPetUIModelList = newPetUiModelList - ) - } - lastPoint = newPoint - speedList = - (speedList + (if (lastPoint != null) data.distance / ((newPoint.timestamp - (lastPoint?.timestamp - ?: newPoint.timestamp)).coerceAtLeast(1000L) / 1000.0) else 0.0)).takeLast( - 100 - ) - } - - is DomainResult.Error, is DomainResult.Exception -> { - _uiState.update { state -> - state.copy(pathPoints = state.pathPoints + LatLng(lat, lng)) - } - lastPoint = newPoint + init { + viewModelScope.launch { + dataManager.trackingData.collect { serviceData -> + updateUiStateFromService(serviceData) } } } - fun updateTime(time: Int) { - _uiState.update { it.copy(time = time) } + private fun updateUiStateFromService(serviceData: WalkTrackingDataManager.TrackingData) { + val exerciseType = try { + ExerciseType.valueOf(serviceData.exerciseType) + } catch (e: IllegalArgumentException) { + ExerciseType.WALKING + } + + val distanceKm = serviceData.distance / 1000.0 + val rounded = BigDecimal(distanceKm).setScale(2, RoundingMode.HALF_UP).toDouble() + + val memberUiModel = serviceData.member?.let { member -> + val memberCalorie = calculateMemberCalorieUseCase( + exerciseType, + member.member.gender.name, + member.member.weight.toDouble(), + rounded + ).roundToInt() + member.copy(calorie = memberCalorie) + } + + val petUiModelList = serviceData.petList?.map { petUiModel -> + val pet = petUiModel.pet + val petCalorie = calculatePetCalorieUseCase( + pet.weight, + rounded, + pet.runStyle.activityFactor + ).roundToInt() + petUiModel.copy(calorie = petCalorie) + } + + _uiState.update { state -> + state.copy( + time = serviceData.time, + distance = serviceData.distance, + pathPoints = serviceData.pathPoints, + isPaused = serviceData.isPaused, + exerciseType = exerciseType, + walkMemberUiModel = memberUiModel, + walkPetUIModelList = petUiModelList + ) + } } fun togglePause() { - _uiState.update { it.copy(isPaused = !it.isPaused) } + if (_uiState.value.isPaused) { + serviceHelper.resumeTracking() + } else { + serviceHelper.pauseTracking() + } } fun emitShowBottomSheet(type: BottomSheetType) { @@ -128,12 +112,44 @@ class WalkTrackingViewModel @Inject constructor( member: WalkMemberUiModel, petList: List, ) { - _uiState.update { state -> - state.copy( - walkMemberUiModel = member, - walkPetUIModelList = petList, - exerciseType = exerciseType - ) + val initialDistance = 0.0 + val memberCalorie = calculateMemberCalorieUseCase( + exerciseType, + member.member.gender.name, + member.member.weight.toDouble(), + initialDistance + ).roundToInt() + + val initialPetList = petList.map { petUiModel -> + val pet = petUiModel.pet + val petCalorie = calculatePetCalorieUseCase( + pet.weight, + initialDistance, + pet.runStyle.activityFactor + ).roundToInt() + petUiModel.copy(calorie = petCalorie) + } + + val initialMember = member.copy(calorie = memberCalorie) + + dataManager.updateInitialData( + exerciseType.name, + initialMember, + initialPetList + ) + + // 서비스 시작 + serviceHelper.startTracking(exerciseType) + } + + fun stopTracking() { + serviceHelper.stopTracking() + } + + override fun onCleared() { + super.onCleared() + if (serviceHelper.isTracking()) { + serviceHelper.stopTracking() } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ff0f000..c60d28a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ accompanistPermissions = "0.37.0" coreSplashscreen = "1.0.1" graphicsShapes = "1.0.1" +gson = "2.10.1" kotlin = "2.0.0" jetbrainsKotlinJvm = "2.0.0" kotlinxSerializationJson = "1.7.0" @@ -77,6 +78,7 @@ androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lif androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } # Koitln +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } kotlin-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } ## Coroutine From db744d2126492b5fa3d83e447218e1e7d9dc5376 Mon Sep 17 00:00:00 2001 From: changs97 Date: Fri, 15 Aug 2025 16:43:09 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=EB=B2=A0=EC=9D=B4=EC=8A=A4=20=EB=B0=B0=ED=8F=AC=20=EC=8B=9C=20?= =?UTF-8?q?noti=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/android.yml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 8aa920c..163b8a5 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -215,16 +215,13 @@ jobs: with: appId: ${{secrets.FIREBASE_APP_ID}} serviceCredentialsFileContent: ${{ secrets.CREDENTIAL_FILE_CONTENT }} - groups: testers + groups: testers,beta-testers file: app/build/outputs/apk/prod/release/app-prod-release.apk releaseNotes: | - RunCombi Android v${{ steps.app_version.outputs.version_name }} + 🚀 RunCombi Android v${{ steps.app_version.outputs.version_name }} 배포 완료! - PR: ${{ github.event.pull_request.title }} - Author: @${{ github.event.pull_request.user.login }} - Branch: ${{ github.event.pull_request.head.ref }} → ${{ github.event.pull_request.base.ref }} - - APK: Prod Release Version + 📱 **앱 버전**: v${{ steps.app_version.outputs.version_name }} (${{ steps.app_version.outputs.version_code }}) + notifyTesters: true - name: If Success, Send notification on Slack if: ${{success()}} From ae9798bb542c7c79f229f70266fe700146279ffb Mon Sep 17 00:00:00 2001 From: changs97 Date: Fri, 15 Aug 2025 16:43:39 +0900 Subject: [PATCH 3/5] =?UTF-8?q?release:=20=ED=94=84=EB=A1=9C=EB=8D=95?= =?UTF-8?q?=EC=85=98=20=EB=B2=84=EC=A0=84=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=201.0.98108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 03109c0..c20ce0f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.combo.runcombi" minSdk = 26 targetSdk = 35 - versionCode = 107 - versionName = "1.0.7" + versionCode = 108 + versionName = "1.0.8" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From 59ecdd34605df30f2bcecdf534509a3a334d95f7 Mon Sep 17 00:00:00 2001 From: changs97 Date: Fri, 15 Aug 2025 16:43:39 +0900 Subject: [PATCH 4/5] =?UTF-8?q?release:=20=ED=94=84=EB=A1=9C=EB=8D=95?= =?UTF-8?q?=EC=85=98=20=EB=B2=84=EC=A0=84=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=201.0.9(109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 03109c0..c20ce0f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.combo.runcombi" minSdk = 26 targetSdk = 35 - versionCode = 107 - versionName = "1.0.7" + versionCode = 108 + versionName = "1.0.8" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From 25d3617d27b05926dde5a7e8875914f0b37681bd Mon Sep 17 00:00:00 2001 From: changs97 Date: Fri, 15 Aug 2025 17:14:30 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20ci/cd=20=ED=85=8C=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=20=EB=8C=80=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/android.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 163b8a5..254e8dc 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -215,7 +215,7 @@ jobs: with: appId: ${{secrets.FIREBASE_APP_ID}} serviceCredentialsFileContent: ${{ secrets.CREDENTIAL_FILE_CONTENT }} - groups: testers,beta-testers + groups: testers file: app/build/outputs/apk/prod/release/app-prod-release.apk releaseNotes: | 🚀 RunCombi Android v${{ steps.app_version.outputs.version_name }} 배포 완료!