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