diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index 8aa920c..254e8dc 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -218,13 +218,10 @@ jobs:
groups: 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()}}
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"
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