diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d7bc0a3..08ca430 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -50,5 +50,6 @@ dependencies { implementation(projects.core.designsystem) implementation(projects.core.domain) implementation(projects.core.navigation) + implementation(projects.feature.alarm) implementation(projects.feature.home) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fc5981c..74a2bd1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,7 +3,9 @@ xmlns:tools="http://schemas.android.com/tools"> - + + + + + - navController.navigate(HomeGraph.BusRouteRoute(routeId, routeNo, routeType, startNodeName, endNodeName, cityCode)) + navigateToBusRoute = { busRoute -> + navController.navigate( + HomeGraph.BusRouteRoute( + busRoute.routeId, + busRoute.routeNo, + busRoute.routeType, + busRoute.startNodeName, + busRoute.endNodeName, + busRoute.cityCode.name, + ), + ) }, - navigateToBusNode = { nodeId, nodeName, nodeNo, cityCode -> - navController.navigate(HomeGraph.BusNodeRoute(nodeId, nodeName, nodeNo, cityCode)) + navigateToBusNode = { busNode -> + navController.navigate( + HomeGraph.BusNodeRoute(busNode.nodeId, busNode.nodeName, busNode.nodeNo, busNode.cityCode.name), + ) }, navigateBack = { navController.popBackStack() }, navigateToHome = { @@ -31,12 +48,44 @@ fun AppNavHost( popUpTo(HomeBaseRoute) { inclusive = false } } }, - navigateToAlarmSetting = { routeId, routeNo -> - navController.navigate(HomeGraph.AlarmSettingRoute(routeId, routeNo)) + navigateToAlarmSetting = { routeId, routeNo, boardingNodeId, boardingNodeName -> + navController.navigate(HomeGraph.AlarmSettingRoute(routeId, routeNo, boardingNodeId, boardingNodeName)) + }, + navigateToAlarmMonitor = { + navController.navigate(HomeGraph.AlarmMonitorRoute) { + popUpTo(HomeGraph.HomeRoute) { inclusive = false } + } }, navigateToAlarmRing = { - navController.navigate(HomeGraph.AlarmRingRoute) + navController.navigate(HomeGraph.AlarmRingRoute) { + launchSingleTop = true + } + }, + ) + alarmGraph( + navigateBack = { navController.popBackStack() }, + onAlarmSet = { + navController.navigate(HomeGraph.AlarmMonitorRoute) { + popUpTo(HomeGraph.HomeRoute) { inclusive = false } + } + }, + onAlarmStopped = { + navController.navigate(HomeGraph.HomeRoute) { + popUpTo(HomeBaseRoute) { inclusive = false } + } + }, + navigateToAlarmRing = { + navController.navigate(HomeGraph.AlarmRingRoute) { + launchSingleTop = true + } + }, + onAlarmDismissed = { + navController.navigate(HomeGraph.HomeRoute) { + popUpTo(HomeBaseRoute) { inclusive = false } + } }, + startAlarmService = { routeNo, nodeName -> startAlarmService(context, routeNo, nodeName) }, + stopAlarmService = { stopAlarmService(context) }, ) } } diff --git a/feature/home/src/main/java/com/choiminjun/home/service/AlarmForegroundService.kt b/app/src/main/java/com/choiminjun/stopreminder/service/AlarmForegroundService.kt similarity index 63% rename from feature/home/src/main/java/com/choiminjun/home/service/AlarmForegroundService.kt rename to app/src/main/java/com/choiminjun/stopreminder/service/AlarmForegroundService.kt index 22c9450..770815f 100644 --- a/feature/home/src/main/java/com/choiminjun/home/service/AlarmForegroundService.kt +++ b/app/src/main/java/com/choiminjun/stopreminder/service/AlarmForegroundService.kt @@ -1,4 +1,4 @@ -package com.choiminjun.home.service +package com.choiminjun.stopreminder.service import android.app.Notification import android.app.NotificationChannel @@ -9,17 +9,24 @@ import android.content.Context import android.content.Intent import android.os.IBinder import androidx.core.content.ContextCompat +import com.choiminjun.domain.model.alarm.AlarmInfo import com.choiminjun.domain.repository.AlarmRepository -import com.choiminjun.home.R +import com.choiminjun.domain.usecase.ObserveNearestNodeUseCase import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CancellationException 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.flow.first +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds +import com.choiminjun.alarm.R as AlarmR @AndroidEntryPoint class AlarmForegroundService : Service() { @@ -27,8 +34,12 @@ class AlarmForegroundService : Service() { @Inject lateinit var alarmRepository: AlarmRepository + @Inject + lateinit var observeNearestNode: ObserveNearestNodeUseCase + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var observeJob: Job? = null + private var locationJob: Job? = null override fun onBind(intent: Intent?): IBinder? = null @@ -44,6 +55,10 @@ class AlarmForegroundService : Service() { if (routeNo != null && nodeName != null) { startForeground(NOTIFICATION_ID, buildNotification(routeNo, nodeName)) observeAlarmState() + scope.launch { + val alarm = alarmRepository.observeAlarm().first() + if (alarm.routeId.isNotBlank()) startLocationTracking(alarm) + } } else { scope.launch { val alarm = alarmRepository.observeAlarm().first() @@ -52,6 +67,7 @@ class AlarmForegroundService : Service() { } else { startForeground(NOTIFICATION_ID, buildNotification(alarm.routeNo, alarm.destNodeName)) observeAlarmState() + startLocationTracking(alarm) } } } @@ -63,7 +79,32 @@ class AlarmForegroundService : Service() { observeJob?.cancel() observeJob = scope.launch { alarmRepository.observeAlarm().collect { alarm -> - if (alarm.routeId.isBlank()) stopSelf() + if (alarm.routeId.isBlank()) { + stopSelf() + } + } + } + } + + private fun startLocationTracking(alarmInfo: AlarmInfo) { + locationJob?.cancel() + locationJob = scope.launch { + while (isActive) { + try { + observeNearestNode(alarmInfo.routeId, alarmInfo.boardingNodeId, alarmInfo.destNodeId) + .collect { result -> + alarmRepository.updateNearestNode(result) + if (result.remaining in 0..alarmInfo.stopsBeforeAlarm) { + alarmRepository.triggerAlarm() + stopSelf() + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "위치 추적 오류, 5초 후 재시도") + delay(5.seconds) + } } } } @@ -72,6 +113,7 @@ class AlarmForegroundService : Service() { val tapIntent = packageManager.getLaunchIntentForPackage(packageName) ?.apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP } ?: Intent() + val pendingIntent = PendingIntent.getActivity( this, 0, @@ -80,8 +122,8 @@ class AlarmForegroundService : Service() { ) return Notification.Builder(this, CHANNEL_ID) - .setContentTitle(getString(R.string.alarm_notification_title)) - .setContentText(getString(R.string.alarm_notification_text, routeNo, nodeName)) + .setContentTitle(getString(AlarmR.string.alarm_notification_title)) + .setContentText(getString(AlarmR.string.alarm_notification_text, routeNo, nodeName)) .setSmallIcon(android.R.drawable.ic_dialog_alert) .setContentIntent(pendingIntent) .setOngoing(true) @@ -91,7 +133,7 @@ class AlarmForegroundService : Service() { private fun createNotificationChannel() { val channel = NotificationChannel( CHANNEL_ID, - getString(R.string.alarm_notification_channel_name), + getString(AlarmR.string.alarm_notification_channel_name), NotificationManager.IMPORTANCE_HIGH, ) val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -100,6 +142,7 @@ class AlarmForegroundService : Service() { override fun onDestroy() { super.onDestroy() + locationJob?.cancel() scope.cancel() } @@ -111,7 +154,7 @@ class AlarmForegroundService : Service() { } } -internal fun startAlarmService(context: Context, routeNo: String, nodeName: String) { +fun startAlarmService(context: Context, routeNo: String, nodeName: String) { val intent = Intent(context, AlarmForegroundService::class.java).apply { putExtra(AlarmForegroundService.EXTRA_ROUTE_NO, routeNo) putExtra(AlarmForegroundService.EXTRA_NODE_NAME, nodeName) @@ -119,6 +162,6 @@ internal fun startAlarmService(context: Context, routeNo: String, nodeName: Stri ContextCompat.startForegroundService(context, intent) } -internal fun stopAlarmService(context: Context) { +fun stopAlarmService(context: Context) { context.stopService(Intent(context, AlarmForegroundService::class.java)) } diff --git a/build-logic/src/main/kotlin/com/choiminjun/build/logic/KotlinAndroid.kt b/build-logic/src/main/kotlin/com/choiminjun/build/logic/KotlinAndroid.kt index 6b46d60..3080de1 100644 --- a/build-logic/src/main/kotlin/com/choiminjun/build/logic/KotlinAndroid.kt +++ b/build-logic/src/main/kotlin/com/choiminjun/build/logic/KotlinAndroid.kt @@ -41,6 +41,7 @@ internal fun Project.configureKotlinAndroid() { val libs = extensions.libs dependencies { "detektPlugins"(libs.findLibrary("detekt.formatting").get()) + "implementation"(libs.findLibrary("timber").get()) } configureKotlin() diff --git a/core/common/src/main/java/com/choiminjun/common/util/DistanceUtil.kt b/core/common/src/main/java/com/choiminjun/common/util/DistanceUtil.kt new file mode 100644 index 0000000..8e3c27e --- /dev/null +++ b/core/common/src/main/java/com/choiminjun/common/util/DistanceUtil.kt @@ -0,0 +1,25 @@ +package com.choiminjun.common.util + +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.sin +import kotlin.math.sqrt + +private const val EARTH_RADIUS_METERS = 6_371_000.0 + +// 하버사인 공식 +fun calculateDistance( + lat1: Double, + lon1: Double, + lat2: Double, + lon2: Double, +): Double { + val phi1 = Math.toRadians(lat1) + val phi2 = Math.toRadians(lat2) + val deltaPhi = Math.toRadians(lat2 - lat1) + val deltaLambda = Math.toRadians(lon2 - lon1) + + val a = sin(deltaPhi / 2).pow(2) + cos(phi1) * cos(phi2) * sin(deltaLambda / 2).pow(2) + return EARTH_RADIUS_METERS * 2 * atan2(sqrt(a), sqrt(1 - a)) +} diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 0700bc7..13ea9ce 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -13,4 +13,5 @@ dependencies { implementation(projects.core.network) implementation(projects.core.database) implementation(projects.core.datastore) + implementation(libs.play.services.location) } diff --git a/core/data/src/main/java/com/choiminjun/data/di/DataModule.kt b/core/data/src/main/java/com/choiminjun/data/di/DataModule.kt index 72e9229..effb137 100644 --- a/core/data/src/main/java/com/choiminjun/data/di/DataModule.kt +++ b/core/data/src/main/java/com/choiminjun/data/di/DataModule.kt @@ -3,10 +3,12 @@ package com.choiminjun.data.di import com.choiminjun.data.repository.AlarmRepositoryImpl import com.choiminjun.data.repository.BusRepositoryImpl import com.choiminjun.data.repository.FavoriteRepositoryImpl +import com.choiminjun.data.repository.LocationRepositoryImpl import com.choiminjun.data.repository.RecentSearchRepositoryImpl import com.choiminjun.domain.repository.AlarmRepository import com.choiminjun.domain.repository.BusRepository import com.choiminjun.domain.repository.FavoriteRepository +import com.choiminjun.domain.repository.LocationRepository import com.choiminjun.domain.repository.RecentSearchRepository import dagger.Binds import dagger.Module @@ -34,4 +36,8 @@ abstract class DataModule { @Binds @Singleton abstract fun bindFavoriteRepository(impl: FavoriteRepositoryImpl): FavoriteRepository + + @Binds + @Singleton + abstract fun bindLocationRepository(impl: LocationRepositoryImpl): LocationRepository } diff --git a/core/data/src/main/java/com/choiminjun/data/repository/AlarmRepositoryImpl.kt b/core/data/src/main/java/com/choiminjun/data/repository/AlarmRepositoryImpl.kt index 8e80e68..894baa7 100644 --- a/core/data/src/main/java/com/choiminjun/data/repository/AlarmRepositoryImpl.kt +++ b/core/data/src/main/java/com/choiminjun/data/repository/AlarmRepositoryImpl.kt @@ -2,6 +2,7 @@ package com.choiminjun.data.repository import com.choiminjun.datastore.source.AlarmDataSource import com.choiminjun.domain.model.alarm.AlarmInfo +import com.choiminjun.domain.model.location.NearestNodeResult import com.choiminjun.domain.repository.AlarmRepository import kotlinx.coroutines.flow.Flow import javax.inject.Inject @@ -16,4 +17,10 @@ class AlarmRepositoryImpl @Inject constructor( override suspend fun setAlarm(alarm: AlarmInfo) = alarmDataSource.setAlarm(alarm) override suspend fun clearAlarm() = alarmDataSource.clearAlarm() + + override suspend fun triggerAlarm() = alarmDataSource.triggerAlarm() + + override fun observeNearestNode(): Flow = alarmDataSource.nearestNode + + override fun updateNearestNode(result: NearestNodeResult) = alarmDataSource.updateNearestNode(result) } diff --git a/core/data/src/main/java/com/choiminjun/data/repository/LocationRepositoryImpl.kt b/core/data/src/main/java/com/choiminjun/data/repository/LocationRepositoryImpl.kt new file mode 100644 index 0000000..6df081d --- /dev/null +++ b/core/data/src/main/java/com/choiminjun/data/repository/LocationRepositoryImpl.kt @@ -0,0 +1,109 @@ +package com.choiminjun.data.repository + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Looper +import com.choiminjun.common.util.calculateDistance +import com.choiminjun.domain.model.bus.BusNode +import com.choiminjun.domain.model.location.Coordinate +import com.choiminjun.domain.model.location.NearestNodeResult +import com.choiminjun.domain.repository.LocationRepository +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.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.mapNotNull +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LocationRepositoryImpl @Inject constructor( + @ApplicationContext private val context: Context, +) : LocationRepository { + + private val fusedClient = LocationServices.getFusedLocationProviderClient(context) + + @SuppressLint("MissingPermission") + override fun observeLocation(): Flow = callbackFlow { + val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, LOCATION_INTERVAL_MS) + .setMinUpdateIntervalMillis(LOCATION_MIN_INTERVAL_MS) + .build() + + val callback = object : LocationCallback() { + override fun onLocationResult(result: LocationResult) { + result.lastLocation?.let { location -> + Timber.d("위치 수신: lat=${location.latitude}, lon=${location.longitude}, accuracy=${location.accuracy}m") + trySend(Coordinate(location.latitude, location.longitude)) + } + } + } + + fusedClient.requestLocationUpdates(request, callback, Looper.getMainLooper()) + + awaitClose { + fusedClient.removeLocationUpdates(callback) + } + } + + override fun observeNearestNode( + nodes: List, + boardingIndex: Int, + destIndex: Int, + ): Flow = flow { + val windowNodes = nodes.subList(boardingIndex, destIndex + 1).toMutableList() + var windowStart = boardingIndex + + Timber.d("노드 윈도우 초기화: boardingIndex=$boardingIndex, destIndex=$destIndex, windowSize=${windowNodes.size}") + + emitAll( + observeLocation().mapNotNull { coord -> + if (windowNodes.isEmpty()) return@mapNotNull null + + val nearestIdx = windowNodes.indices.minByOrNull { i -> + calculateDistance( + coord.latitude, coord.longitude, + windowNodes[i].latitude!!, windowNodes[i].longitude!!, + ) + } ?: return@mapNotNull null + + if (nearestIdx > 0) { + windowStart += nearestIdx + repeat(nearestIdx) { windowNodes.removeAt(0) } + Timber.d("윈도우 전진: nearestIdx=$nearestIdx, windowStart=$windowStart, remaining=${windowNodes.size - 1}") + } + + val nearestNode = windowNodes[0] + val remaining = windowNodes.size - 1 + val distance = calculateDistance( + coord.latitude, + coord.longitude, + nearestNode.latitude!!, + nearestNode.longitude!!, + ) + + Timber.d("최근접 정류장: [${nearestNode.nodeName}] remaining=$remaining, distance=${distance.toInt()}m") + + NearestNodeResult( + nearestNodeIndex = windowStart, + destIndex = destIndex, + nearestNodeName = nearestNode.nodeName, + distanceMeters = distance, + remaining = remaining, + ) + }, + ) + } + + companion object { + private const val LOCATION_INTERVAL_MS = 10_000L + private const val LOCATION_MIN_INTERVAL_MS = 7_000L + } +} diff --git a/core/datastore/src/main/java/com/choiminjun/datastore/source/AlarmDataSource.kt b/core/datastore/src/main/java/com/choiminjun/datastore/source/AlarmDataSource.kt index 05d3add..b63fc50 100644 --- a/core/datastore/src/main/java/com/choiminjun/datastore/source/AlarmDataSource.kt +++ b/core/datastore/src/main/java/com/choiminjun/datastore/source/AlarmDataSource.kt @@ -2,12 +2,17 @@ package com.choiminjun.datastore.source import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import com.choiminjun.domain.model.alarm.AlarmInfo +import com.choiminjun.domain.model.location.NearestNodeResult import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import java.io.IOException @@ -31,7 +36,10 @@ class AlarmDataSource @Inject constructor( routeNo = prefs[KEY_ROUTE_NO] ?: "", destNodeId = prefs[KEY_DEST_NODE_ID] ?: "", destNodeName = prefs[KEY_DEST_NODE_NAME] ?: "", + boardingNodeId = prefs[KEY_BOARDING_NODE_ID] ?: "", + boardingNodeName = prefs[KEY_BOARDING_NODE_NAME] ?: "", stopsBeforeAlarm = prefs[KEY_STOPS_BEFORE] ?: 1, + isTriggered = prefs[KEY_IS_TRIGGERED] ?: false, ) } @@ -41,6 +49,8 @@ class AlarmDataSource @Inject constructor( prefs[KEY_ROUTE_NO] = alarm.routeNo prefs[KEY_DEST_NODE_ID] = alarm.destNodeId prefs[KEY_DEST_NODE_NAME] = alarm.destNodeName + prefs[KEY_BOARDING_NODE_ID] = alarm.boardingNodeId + prefs[KEY_BOARDING_NODE_NAME] = alarm.boardingNodeName prefs[KEY_STOPS_BEFORE] = alarm.stopsBeforeAlarm } } @@ -49,11 +59,27 @@ class AlarmDataSource @Inject constructor( dataStore.edit { it.clear() } } + suspend fun triggerAlarm() { + dataStore.edit { prefs -> + prefs[KEY_IS_TRIGGERED] = true + } + } + + private val _nearestNode = MutableStateFlow(null) + val nearestNode: StateFlow = _nearestNode.asStateFlow() + + fun updateNearestNode(result: NearestNodeResult) { + _nearestNode.value = result + } + private companion object { val KEY_ROUTE_ID = stringPreferencesKey("route_id") val KEY_ROUTE_NO = stringPreferencesKey("route_no") val KEY_DEST_NODE_ID = stringPreferencesKey("dest_node_id") val KEY_DEST_NODE_NAME = stringPreferencesKey("dest_node_name") + val KEY_BOARDING_NODE_ID = stringPreferencesKey("boarding_node_id") + val KEY_BOARDING_NODE_NAME = stringPreferencesKey("boarding_node_name") val KEY_STOPS_BEFORE = intPreferencesKey("stops_before") + val KEY_IS_TRIGGERED = booleanPreferencesKey("is_triggered") } } diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index 7577aa1..1e050b6 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -4,5 +4,6 @@ plugins { } dependencies { + implementation(projects.core.common) implementation(libs.kotlinx.coroutines.core) } diff --git a/core/domain/src/main/java/com/choiminjun/domain/model/alarm/AlarmInfo.kt b/core/domain/src/main/java/com/choiminjun/domain/model/alarm/AlarmInfo.kt index 8e6419c..83572a4 100644 --- a/core/domain/src/main/java/com/choiminjun/domain/model/alarm/AlarmInfo.kt +++ b/core/domain/src/main/java/com/choiminjun/domain/model/alarm/AlarmInfo.kt @@ -5,5 +5,8 @@ data class AlarmInfo( val routeNo: String = "", val destNodeId: String = "", val destNodeName: String = "", + val boardingNodeId: String = "", + val boardingNodeName: String = "", val stopsBeforeAlarm: Int = 1, + val isTriggered: Boolean = false, ) diff --git a/core/domain/src/main/java/com/choiminjun/domain/model/location/Coordinate.kt b/core/domain/src/main/java/com/choiminjun/domain/model/location/Coordinate.kt new file mode 100644 index 0000000..aa17648 --- /dev/null +++ b/core/domain/src/main/java/com/choiminjun/domain/model/location/Coordinate.kt @@ -0,0 +1,6 @@ +package com.choiminjun.domain.model.location + +data class Coordinate( + val latitude: Double, + val longitude: Double, +) diff --git a/core/domain/src/main/java/com/choiminjun/domain/model/location/NearestNodeResult.kt b/core/domain/src/main/java/com/choiminjun/domain/model/location/NearestNodeResult.kt new file mode 100644 index 0000000..07d5af1 --- /dev/null +++ b/core/domain/src/main/java/com/choiminjun/domain/model/location/NearestNodeResult.kt @@ -0,0 +1,9 @@ +package com.choiminjun.domain.model.location + +data class NearestNodeResult( + val nearestNodeIndex: Int, + val destIndex: Int, + val nearestNodeName: String, + val distanceMeters: Double, + val remaining: Int, +) diff --git a/core/domain/src/main/java/com/choiminjun/domain/repository/AlarmRepository.kt b/core/domain/src/main/java/com/choiminjun/domain/repository/AlarmRepository.kt index a1c1043..486be37 100644 --- a/core/domain/src/main/java/com/choiminjun/domain/repository/AlarmRepository.kt +++ b/core/domain/src/main/java/com/choiminjun/domain/repository/AlarmRepository.kt @@ -1,10 +1,14 @@ package com.choiminjun.domain.repository import com.choiminjun.domain.model.alarm.AlarmInfo +import com.choiminjun.domain.model.location.NearestNodeResult import kotlinx.coroutines.flow.Flow interface AlarmRepository { fun observeAlarm(): Flow suspend fun setAlarm(alarm: AlarmInfo) suspend fun clearAlarm() + suspend fun triggerAlarm() + fun observeNearestNode(): Flow + fun updateNearestNode(result: NearestNodeResult) } diff --git a/core/domain/src/main/java/com/choiminjun/domain/repository/LocationRepository.kt b/core/domain/src/main/java/com/choiminjun/domain/repository/LocationRepository.kt new file mode 100644 index 0000000..bb9268f --- /dev/null +++ b/core/domain/src/main/java/com/choiminjun/domain/repository/LocationRepository.kt @@ -0,0 +1,15 @@ +package com.choiminjun.domain.repository + +import com.choiminjun.domain.model.bus.BusNode +import com.choiminjun.domain.model.location.Coordinate +import com.choiminjun.domain.model.location.NearestNodeResult +import kotlinx.coroutines.flow.Flow + +interface LocationRepository { + fun observeLocation(): Flow + fun observeNearestNode( + nodes: List, + boardingIndex: Int, + destIndex: Int, + ): Flow +} diff --git a/core/domain/src/main/java/com/choiminjun/domain/usecase/GetNodesByRouteUseCase.kt b/core/domain/src/main/java/com/choiminjun/domain/usecase/GetNodesByRouteUseCase.kt new file mode 100644 index 0000000..073f174 --- /dev/null +++ b/core/domain/src/main/java/com/choiminjun/domain/usecase/GetNodesByRouteUseCase.kt @@ -0,0 +1,13 @@ +package com.choiminjun.domain.usecase + +import com.choiminjun.domain.model.bus.BusNode +import com.choiminjun.domain.model.bus.CityCode +import com.choiminjun.domain.repository.BusRepository +import javax.inject.Inject + +class GetNodesByRouteUseCase @Inject constructor( + private val busRepository: BusRepository, +) { + suspend operator fun invoke(routeId: String): List = + busRepository.getNodesByRoute(CityCode.BUSAN, routeId) +} diff --git a/core/domain/src/main/java/com/choiminjun/domain/usecase/ObserveAlarmTriggerUseCase.kt b/core/domain/src/main/java/com/choiminjun/domain/usecase/ObserveAlarmTriggerUseCase.kt new file mode 100644 index 0000000..b013671 --- /dev/null +++ b/core/domain/src/main/java/com/choiminjun/domain/usecase/ObserveAlarmTriggerUseCase.kt @@ -0,0 +1,17 @@ +package com.choiminjun.domain.usecase + +import com.choiminjun.domain.model.alarm.AlarmInfo +import com.choiminjun.domain.model.location.NearestNodeResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.take +import javax.inject.Inject + +class ObserveAlarmTriggerUseCase @Inject constructor( + private val observeNearestNode: ObserveNearestNodeUseCase, +) { + operator fun invoke(alarmInfo: AlarmInfo): Flow = + observeNearestNode(alarmInfo.routeId, alarmInfo.boardingNodeId, alarmInfo.destNodeId) + .filter { it.remaining in 0..alarmInfo.stopsBeforeAlarm } + .take(1) +} diff --git a/core/domain/src/main/java/com/choiminjun/domain/usecase/ObserveNearestNodeUseCase.kt b/core/domain/src/main/java/com/choiminjun/domain/usecase/ObserveNearestNodeUseCase.kt new file mode 100644 index 0000000..8fbcdf8 --- /dev/null +++ b/core/domain/src/main/java/com/choiminjun/domain/usecase/ObserveNearestNodeUseCase.kt @@ -0,0 +1,25 @@ +package com.choiminjun.domain.usecase + +import com.choiminjun.domain.model.location.NearestNodeResult +import com.choiminjun.domain.repository.LocationRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +class ObserveNearestNodeUseCase @Inject constructor( + private val getNodesByRoute: GetNodesByRouteUseCase, + private val locationRepository: LocationRepository, +) { + operator fun invoke(routeId: String, boardingNodeId: String, destNodeId: String): Flow = flow { + val allNodes = getNodesByRoute(routeId) + .filter { it.latitude != null && it.longitude != null } + + val boardingIndex = allNodes.indexOfFirst { it.nodeId == boardingNodeId } + val destIndex = allNodes.indexOfFirst { it.nodeId == destNodeId } + + if (boardingIndex == -1 || destIndex == -1 || boardingIndex >= destIndex) return@flow + + emitAll(locationRepository.observeNearestNode(allNodes, boardingIndex, destIndex)) + } +} diff --git a/core/navigation/src/main/java/com/choiminjun/navigation/Route.kt b/core/navigation/src/main/java/com/choiminjun/navigation/Route.kt index b66050e..81731a8 100644 --- a/core/navigation/src/main/java/com/choiminjun/navigation/Route.kt +++ b/core/navigation/src/main/java/com/choiminjun/navigation/Route.kt @@ -30,7 +30,15 @@ sealed interface HomeGraph : Route { ) : HomeGraph @Serializable - data class AlarmSettingRoute(val routeId: String, val routeNo: String) : HomeGraph + data class AlarmSettingRoute( + val routeId: String, + val routeNo: String, + val boardingNodeId: String = "", + val boardingNodeName: String = "", + ) : HomeGraph + + @Serializable + data object AlarmMonitorRoute : HomeGraph @Serializable data object AlarmRingRoute : HomeGraph diff --git a/feature/alarm/.gitignore b/feature/alarm/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature/alarm/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/alarm/build.gradle.kts b/feature/alarm/build.gradle.kts new file mode 100644 index 0000000..792eee4 --- /dev/null +++ b/feature/alarm/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + id("stopreminder.android.feature") +} + +android { + namespace = "com.choiminjun.alarm" +} diff --git a/feature/alarm/src/main/AndroidManifest.xml b/feature/alarm/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9e8a98b --- /dev/null +++ b/feature/alarm/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/feature/alarm/src/main/java/com/choiminjun/alarm/alarmmonitor/AlarmMonitorContract.kt b/feature/alarm/src/main/java/com/choiminjun/alarm/alarmmonitor/AlarmMonitorContract.kt new file mode 100644 index 0000000..818ba74 --- /dev/null +++ b/feature/alarm/src/main/java/com/choiminjun/alarm/alarmmonitor/AlarmMonitorContract.kt @@ -0,0 +1,25 @@ +package com.choiminjun.alarm.alarmmonitor + +import com.choiminjun.base.UiIntent +import com.choiminjun.base.UiSideEffect +import com.choiminjun.base.UiState +import com.choiminjun.domain.model.alarm.AlarmInfo + +data class AlarmMonitorState( + val alarmInfo: AlarmInfo = AlarmInfo(), + val routeNodeNames: List = emptyList(), + val isLoadingNodes: Boolean = true, + val nearestNodeName: String? = null, + val remainingStops: Int? = null, +) : UiState + +sealed interface AlarmMonitorIntent : UiIntent { + data object ClickBack : AlarmMonitorIntent + data object StopAlarm : AlarmMonitorIntent +} + +sealed interface AlarmMonitorSideEffect : UiSideEffect { + data object NavigateBack : AlarmMonitorSideEffect + data object AlarmStopped : AlarmMonitorSideEffect + data object AlarmTriggered : AlarmMonitorSideEffect +} diff --git a/feature/alarm/src/main/java/com/choiminjun/alarm/alarmmonitor/AlarmMonitorScreen.kt b/feature/alarm/src/main/java/com/choiminjun/alarm/alarmmonitor/AlarmMonitorScreen.kt new file mode 100644 index 0000000..ad00153 --- /dev/null +++ b/feature/alarm/src/main/java/com/choiminjun/alarm/alarmmonitor/AlarmMonitorScreen.kt @@ -0,0 +1,363 @@ +package com.choiminjun.alarm.alarmmonitor + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.choiminjun.designsystem.component.SRIconButton +import com.choiminjun.designsystem.theme.SRTheme +import com.choiminjun.designsystem.theme.Spacing +import com.choiminjun.domain.model.alarm.AlarmInfo +import com.choiminjun.alarm.R as HR +import com.choiminjun.designsystem.R as DesignR + +private val NodeIconSize = 20.dp +private val NodeBadgeSize = 32.dp +private val NodeIconAreaWidth = 52.dp +private val NodeRowHeight = 64.dp + +@Composable +internal fun AlarmMonitorRoute( + onBackClick: () -> Unit, + onAlarmStopped: () -> Unit, + navigateToAlarmRing: () -> Unit, + viewModel: AlarmMonitorViewModel = hiltViewModel(), +) { + val state by viewModel.collectAsState() + + viewModel.collectSideEffect { effect -> + when (effect) { + AlarmMonitorSideEffect.NavigateBack -> onBackClick() + AlarmMonitorSideEffect.AlarmStopped -> onAlarmStopped() + AlarmMonitorSideEffect.AlarmTriggered -> navigateToAlarmRing() + } + } + + AlarmMonitorScreen( + state = state, + onBackClick = { viewModel.onIntent(AlarmMonitorIntent.ClickBack) }, + onStopAlarm = { viewModel.onIntent(AlarmMonitorIntent.StopAlarm) }, + ) +} + +@Composable +private fun AlarmMonitorScreen( + state: AlarmMonitorState, + onBackClick: () -> Unit, + onStopAlarm: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(SRTheme.colors.background) + .statusBarsPadding() + .navigationBarsPadding(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.space8, vertical = Spacing.space12), + verticalAlignment = Alignment.CenterVertically, + ) { + SRIconButton( + imageVector = ImageVector.vectorResource(DesignR.drawable.ic_arrow_left), + contentDescription = stringResource(HR.string.back), + onClick = onBackClick, + ) + Spacer(Modifier.weight(1f)) + TextButton(onClick = onStopAlarm) { + Text( + text = stringResource(HR.string.alarm_stop_button), + style = SRTheme.typography.bodyMM, + color = SRTheme.colors.blue50, + ) + } + } + + TrackingStatusText( + nearestNodeName = state.nearestNodeName, + remainingStops = state.remainingStops, + ) + + if (state.isLoadingNodes) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = SRTheme.colors.blue50) + } + } else { + RouteOverview( + modifier = Modifier.weight(1f), + routeNodeNames = state.routeNodeNames, + boardingNodeName = state.alarmInfo.boardingNodeName, + destNodeName = state.alarmInfo.destNodeName, + nearestNodeName = state.nearestNodeName, + ) + } + } +} + +@Composable +private fun TrackingStatusText( + nearestNodeName: String?, + remainingStops: Int?, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.space20, vertical = Spacing.space8), + verticalArrangement = Arrangement.spacedBy(Spacing.space4), + ) { + if (nearestNodeName == null) { + Text( + text = stringResource(HR.string.alarm_tracking_status), + style = SRTheme.typography.bodyMM, + color = SRTheme.colors.textPrimary, + ) + } else { + Text( + text = stringResource(HR.string.alarm_current_node_label, nearestNodeName), + style = SRTheme.typography.bodyMM, + color = SRTheme.colors.textPrimary, + ) + + if (remainingStops != null) { + Text( + text = stringResource(HR.string.alarm_remaining_stops, remainingStops), + style = SRTheme.typography.bodySR, + color = SRTheme.colors.textPrimary, + ) + } + } + } +} + +@Composable +private fun RouteOverview( + modifier: Modifier = Modifier, + routeNodeNames: List, + boardingNodeName: String, + destNodeName: String, + nearestNodeName: String? = null, +) { + val lineColor = SRTheme.colors.blue50 + val displayNodes = routeNodeNames.ifEmpty { + listOfNotNull(boardingNodeName.ifBlank { null }, destNodeName.ifBlank { null }) + } + val lastIndex = displayNodes.lastIndex + + LazyColumn( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = Spacing.space20), + ) { + itemsIndexed(displayNodes, key = { _, name -> name }) { index, nodeName -> + val badgeLabel = when (index) { + 0 -> stringResource(HR.string.alarm_boarding_label) + lastIndex -> stringResource(HR.string.alarm_dest_label) + else -> null + } + RouteNodeRow( + nodeName = nodeName, + badgeLabel = badgeLabel, + isFirst = index == 0, + isLast = index == lastIndex, + isCurrentNode = nodeName == nearestNodeName, + ) + } + } +} + +@Composable +private fun RouteNodeRow( + nodeName: String, + badgeLabel: String?, + isFirst: Boolean, + isLast: Boolean, + isCurrentNode: Boolean, +) { + val lineColor = SRTheme.colors.blue50 + val iconColor = SRTheme.colors.coolNeutral70 + Row( + modifier = Modifier + .fillMaxWidth() + .height(NodeRowHeight), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .width(NodeIconAreaWidth) + .fillMaxHeight() + .drawBehind { + val iconCenterX = size.width / 2f + val centerY = size.height / 2f + val halfIcon = if (badgeLabel != null || isCurrentNode) NodeBadgeSize.toPx() / 2f else NodeIconSize.toPx() / 2f + val strokeWidth = 2.dp.toPx() + if (!isFirst) { + drawLine( + color = lineColor, + start = Offset(iconCenterX, 0f), + end = Offset(iconCenterX, centerY - halfIcon), + strokeWidth = strokeWidth, + ) + } + if (!isLast) { + drawLine( + color = lineColor, + start = Offset(iconCenterX, centerY + halfIcon), + end = Offset(iconCenterX, size.height), + strokeWidth = strokeWidth, + ) + } + }, + contentAlignment = Alignment.Center, + ) { + when { + isCurrentNode -> Icon( + imageVector = ImageVector.vectorResource(DesignR.drawable.ic_bus), + contentDescription = null, + tint = SRTheme.colors.blue50, + modifier = Modifier.size(NodeBadgeSize), + ) + badgeLabel != null -> Box( + modifier = Modifier + .size(NodeBadgeSize) + .border(1.dp, iconColor, CircleShape), + contentAlignment = Alignment.Center, + ) { + Text( + text = badgeLabel, + style = SRTheme.typography.bodyXSSB, + color = iconColor, + ) + } + else -> Icon( + imageVector = ImageVector.vectorResource(DesignR.drawable.ic_chevron_down_circle), + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(NodeIconSize), + ) + } + } + Text( + modifier = Modifier.weight(1f), + text = nodeName, + style = SRTheme.typography.bodyMM, + color = SRTheme.colors.textPrimary, + ) + } +} + +private val previewAlarmInfo = AlarmInfo( + routeNo = "51", + boardingNodeName = "부산대학교앞", + destNodeName = "하단", + stopsBeforeAlarm = 2, +) + +private val previewRouteNodes = listOf( + "부산대학교앞", "온천장역", "부암시장", "동래역", "명장동", + "수영교차로", "광안리해수욕장", "민락동", "하단", +) + +@Preview(showBackground = true, name = "1_Loading") +@Composable +private fun AlarmMonitorLoadingPreview() { + SRTheme { + AlarmMonitorScreen( + state = AlarmMonitorState( + alarmInfo = previewAlarmInfo, + isLoadingNodes = true, + ), + onBackClick = {}, + onStopAlarm = {}, + ) + } +} + +@Preview(showBackground = true, name = "2_Tracking") +@Composable +private fun AlarmMonitorTrackingPreview() { + SRTheme { + AlarmMonitorScreen( + state = AlarmMonitorState( + alarmInfo = previewAlarmInfo, + routeNodeNames = previewRouteNodes, + isLoadingNodes = false, + nearestNodeName = null, + ), + onBackClick = {}, + onStopAlarm = {}, + ) + } +} + +@Preview(showBackground = true, name = "3_Location Found") +@Composable +private fun AlarmMonitorLocationFoundPreview() { + SRTheme { + AlarmMonitorScreen( + state = AlarmMonitorState( + alarmInfo = previewAlarmInfo, + routeNodeNames = previewRouteNodes, + isLoadingNodes = false, + nearestNodeName = "동래역", + remainingStops = 4, + ), + onBackClick = {}, + onStopAlarm = {}, + ) + } +} + +@Preview(showBackground = true, name = "4_Almost Arrived") +@Composable +private fun AlarmMonitorAlmostArrivedPreview() { + SRTheme { + AlarmMonitorScreen( + state = AlarmMonitorState( + alarmInfo = previewAlarmInfo, + routeNodeNames = previewRouteNodes, + isLoadingNodes = false, + nearestNodeName = "민락동", + remainingStops = 1, + ), + onBackClick = {}, + onStopAlarm = {}, + ) + } +} diff --git a/feature/alarm/src/main/java/com/choiminjun/alarm/alarmmonitor/AlarmMonitorViewModel.kt b/feature/alarm/src/main/java/com/choiminjun/alarm/alarmmonitor/AlarmMonitorViewModel.kt new file mode 100644 index 0000000..ae758b1 --- /dev/null +++ b/feature/alarm/src/main/java/com/choiminjun/alarm/alarmmonitor/AlarmMonitorViewModel.kt @@ -0,0 +1,84 @@ +package com.choiminjun.alarm.alarmmonitor + +import androidx.lifecycle.viewModelScope +import com.choiminjun.base.BaseViewModel +import com.choiminjun.common.util.suspendRunCatching +import com.choiminjun.domain.model.alarm.AlarmInfo +import com.choiminjun.domain.repository.AlarmRepository +import com.choiminjun.domain.usecase.GetNodesByRouteUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AlarmMonitorViewModel @Inject constructor( + private val alarmRepository: AlarmRepository, + private val getNodesByRoute: GetNodesByRouteUseCase, +) : BaseViewModel( + initialState = AlarmMonitorState(), +) { + init { + observeAlarm() + observeNearestNode() + } + + override suspend fun handleIntent(intent: AlarmMonitorIntent) { + when (intent) { + AlarmMonitorIntent.ClickBack -> postSideEffect(AlarmMonitorSideEffect.NavigateBack) + AlarmMonitorIntent.StopAlarm -> stopAlarm() + } + } + + private fun observeAlarm() { + viewModelScope.launch { + alarmRepository.observeAlarm().collect { alarmInfo -> + if (alarmInfo.isTriggered) { + postSideEffect(AlarmMonitorSideEffect.AlarmTriggered) + } else { + reduce { copy(alarmInfo = alarmInfo) } + if (state.value.routeNodeNames.isEmpty() && alarmInfo.routeId.isNotBlank()) { + loadRouteNodes(alarmInfo) + } + } + } + } + } + + private fun observeNearestNode() { + viewModelScope.launch { + alarmRepository.observeNearestNode().collect { result -> + reduce { + copy( + nearestNodeName = result?.nearestNodeName, + remainingStops = result?.remaining, + ) + } + } + } + } + + private fun loadRouteNodes(alarmInfo: AlarmInfo) { + viewModelScope.launch { + val nodes = suspendRunCatching { getNodesByRoute(alarmInfo.routeId) } + .getOrElse { emptyList() } + + val boardingIndex = nodes.indexOfFirst { it.nodeId == alarmInfo.boardingNodeId } + val destIndex = nodes.indexOfFirst { it.nodeId == alarmInfo.destNodeId } + + if (boardingIndex == -1 || destIndex == -1 || boardingIndex >= destIndex) { + reduce { copy(isLoadingNodes = false) } + return@launch + } + + val nodeNames = nodes.subList(boardingIndex, destIndex + 1).map { it.nodeName } + reduce { copy(routeNodeNames = nodeNames, isLoadingNodes = false) } + } + } + + private fun stopAlarm() { + viewModelScope.launch { + suspendRunCatching { alarmRepository.clearAlarm() } + .onSuccess { postSideEffect(AlarmMonitorSideEffect.AlarmStopped) } + } + } +} diff --git a/feature/home/src/main/java/com/choiminjun/home/alarmring/AlarmRingContract.kt b/feature/alarm/src/main/java/com/choiminjun/alarm/alarmring/AlarmRingContract.kt similarity index 92% rename from feature/home/src/main/java/com/choiminjun/home/alarmring/AlarmRingContract.kt rename to feature/alarm/src/main/java/com/choiminjun/alarm/alarmring/AlarmRingContract.kt index cda5754..95cba19 100644 --- a/feature/home/src/main/java/com/choiminjun/home/alarmring/AlarmRingContract.kt +++ b/feature/alarm/src/main/java/com/choiminjun/alarm/alarmring/AlarmRingContract.kt @@ -1,4 +1,4 @@ -package com.choiminjun.home.alarmring +package com.choiminjun.alarm.alarmring import com.choiminjun.base.UiIntent import com.choiminjun.base.UiSideEffect diff --git a/feature/home/src/main/java/com/choiminjun/home/alarmring/AlarmRingScreen.kt b/feature/alarm/src/main/java/com/choiminjun/alarm/alarmring/AlarmRingScreen.kt similarity index 93% rename from feature/home/src/main/java/com/choiminjun/home/alarmring/AlarmRingScreen.kt rename to feature/alarm/src/main/java/com/choiminjun/alarm/alarmring/AlarmRingScreen.kt index e82239f..5ca0018 100644 --- a/feature/home/src/main/java/com/choiminjun/home/alarmring/AlarmRingScreen.kt +++ b/feature/alarm/src/main/java/com/choiminjun/alarm/alarmring/AlarmRingScreen.kt @@ -1,4 +1,4 @@ -package com.choiminjun.home.alarmring +package com.choiminjun.alarm.alarmring import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -22,18 +22,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.choiminjun.alarm.R import com.choiminjun.designsystem.theme.SRTheme import com.choiminjun.designsystem.theme.Spacing import com.choiminjun.domain.model.alarm.AlarmInfo -import com.choiminjun.home.R -import com.choiminjun.home.service.stopAlarmService import com.choiminjun.designsystem.R as DesignR @Composable @@ -42,14 +40,10 @@ internal fun AlarmRingRoute( viewModel: AlarmRingViewModel = hiltViewModel(), ) { val state by viewModel.collectAsState() - val context = LocalContext.current viewModel.collectSideEffect { effect -> when (effect) { - AlarmRingSideEffect.NavigateToHome -> { - stopAlarmService(context) - onDismiss() - } + AlarmRingSideEffect.NavigateToHome -> onDismiss() } } diff --git a/feature/home/src/main/java/com/choiminjun/home/alarmring/AlarmRingViewModel.kt b/feature/alarm/src/main/java/com/choiminjun/alarm/alarmring/AlarmRingViewModel.kt similarity index 84% rename from feature/home/src/main/java/com/choiminjun/home/alarmring/AlarmRingViewModel.kt rename to feature/alarm/src/main/java/com/choiminjun/alarm/alarmring/AlarmRingViewModel.kt index b16bf1a..593876f 100644 --- a/feature/home/src/main/java/com/choiminjun/home/alarmring/AlarmRingViewModel.kt +++ b/feature/alarm/src/main/java/com/choiminjun/alarm/alarmring/AlarmRingViewModel.kt @@ -1,10 +1,11 @@ -package com.choiminjun.home.alarmring +package com.choiminjun.alarm.alarmring import androidx.lifecycle.viewModelScope import com.choiminjun.base.BaseViewModel import com.choiminjun.common.util.suspendRunCatching import com.choiminjun.domain.repository.AlarmRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @@ -26,9 +27,8 @@ class AlarmRingViewModel @Inject constructor( private fun observeAlarm() { viewModelScope.launch { - alarmRepository.observeAlarm().collect { alarmInfo -> - reduce { copy(alarmInfo = alarmInfo) } - } + val alarmInfo = alarmRepository.observeAlarm().first() + reduce { copy(alarmInfo = alarmInfo) } } } diff --git a/feature/home/src/main/java/com/choiminjun/home/alarmsetting/AlarmSettingContract.kt b/feature/alarm/src/main/java/com/choiminjun/alarm/alarmsetting/AlarmSettingContract.kt similarity index 89% rename from feature/home/src/main/java/com/choiminjun/home/alarmsetting/AlarmSettingContract.kt rename to feature/alarm/src/main/java/com/choiminjun/alarm/alarmsetting/AlarmSettingContract.kt index f3a3de5..01c2478 100644 --- a/feature/home/src/main/java/com/choiminjun/home/alarmsetting/AlarmSettingContract.kt +++ b/feature/alarm/src/main/java/com/choiminjun/alarm/alarmsetting/AlarmSettingContract.kt @@ -1,4 +1,4 @@ -package com.choiminjun.home.alarmsetting +package com.choiminjun.alarm.alarmsetting import com.choiminjun.base.UiIntent import com.choiminjun.base.UiSideEffect @@ -8,6 +8,8 @@ import com.choiminjun.domain.model.bus.BusNode data class AlarmSettingState( val routeNo: String = "", val routeId: String = "", + val boardingNodeId: String = "", + val boardingNodeName: String = "", val isLoading: Boolean = false, val nodes: List = emptyList(), val selectedNode: BusNode? = null, diff --git a/feature/home/src/main/java/com/choiminjun/home/alarmsetting/AlarmSettingScreen.kt b/feature/alarm/src/main/java/com/choiminjun/alarm/alarmsetting/AlarmSettingScreen.kt similarity index 73% rename from feature/home/src/main/java/com/choiminjun/home/alarmsetting/AlarmSettingScreen.kt rename to feature/alarm/src/main/java/com/choiminjun/alarm/alarmsetting/AlarmSettingScreen.kt index bb95bfb..37d802f 100644 --- a/feature/home/src/main/java/com/choiminjun/home/alarmsetting/AlarmSettingScreen.kt +++ b/feature/alarm/src/main/java/com/choiminjun/alarm/alarmsetting/AlarmSettingScreen.kt @@ -1,10 +1,11 @@ -package com.choiminjun.home.alarmsetting +package com.choiminjun.alarm.alarmsetting import android.Manifest import android.app.Activity import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.os.Build import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult @@ -56,13 +57,12 @@ import androidx.compose.ui.unit.dp import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.choiminjun.alarm.R import com.choiminjun.designsystem.component.SRIconButton import com.choiminjun.designsystem.theme.SRTheme import com.choiminjun.designsystem.theme.Spacing import com.choiminjun.domain.model.bus.BusNode import com.choiminjun.domain.model.bus.CityCode -import com.choiminjun.home.R -import com.choiminjun.home.service.startAlarmService import com.choiminjun.designsystem.R as DesignR private val StopItemHeight: Dp = 68.dp @@ -79,45 +79,72 @@ internal fun AlarmSettingRoute( val context = LocalContext.current var pendingAlarm by remember { mutableStateOf(null) } var showPermissionSheet by remember { mutableStateOf(false) } + var showLocationPermissionSheet by remember { mutableStateOf(false) } val notificationPermLauncher = rememberLauncherForActivityResult(RequestPermission()) { granted -> + val alarm = pendingAlarm + pendingAlarm = null if (granted) { - pendingAlarm?.let { effect -> - startAlarmService(context, effect.routeNo, effect.nodeName) + alarm?.let { effect -> onAlarmSet(effect.routeNo, effect.nodeName) } } else { showPermissionSheet = true } + } + + fun checkAndStartWithNotificationPerm(effect: AlarmSettingSideEffect.AlarmConfirmed) { + val hasNotificationPerm = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ( + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + ) + when { + hasNotificationPerm -> { + onAlarmSet(effect.routeNo, effect.nodeName) + } + + ActivityCompat.shouldShowRequestPermissionRationale( + context as Activity, + Manifest.permission.POST_NOTIFICATIONS, + ) -> showPermissionSheet = true + + else -> { + pendingAlarm = effect + notificationPermLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + } + + val locationPermLauncher = rememberLauncherForActivityResult(RequestPermission()) { granted -> + val alarm = pendingAlarm pendingAlarm = null + if (granted) { + alarm?.let { checkAndStartWithNotificationPerm(it) } + } else { + showLocationPermissionSheet = true + } } viewModel.collectSideEffect { effect -> when (effect) { AlarmSettingSideEffect.NavigateBack -> onBackClick() is AlarmSettingSideEffect.AlarmConfirmed -> { - val hasPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ( - ContextCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS, - ) == PackageManager.PERMISSION_GRANTED - ) + val hasLocationPerm = ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION, + ) == PackageManager.PERMISSION_GRANTED when { - hasPermission -> { - startAlarmService(context, effect.routeNo, effect.nodeName) - onAlarmSet(effect.routeNo, effect.nodeName) - } - + hasLocationPerm -> checkAndStartWithNotificationPerm(effect) ActivityCompat.shouldShowRequestPermissionRationale( context as Activity, - Manifest.permission.POST_NOTIFICATIONS, - ) -> { - showPermissionSheet = true - } + Manifest.permission.ACCESS_FINE_LOCATION, + ) -> showLocationPermissionSheet = true else -> { pendingAlarm = effect - notificationPermLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + locationPermLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) } } } @@ -142,6 +169,16 @@ internal fun AlarmSettingRoute( onDismiss = { showPermissionSheet = false }, ) } + + if (showLocationPermissionSheet) { + LocationPermissionBottomSheet( + onConfirm = { + showLocationPermissionSheet = false + openAppSettings(context) + }, + onDismiss = { showLocationPermissionSheet = false }, + ) + } } private fun openNotificationSettings(context: Context) { @@ -152,6 +189,77 @@ private fun openNotificationSettings(context: Context) { ) } +private fun openAppSettings(context: Context) { + context.startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LocationPermissionBottomSheet( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = SRTheme.colors.background, + shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.space20) + .padding(bottom = Spacing.space32), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Spacing.space12), + ) { + Text( + text = stringResource(R.string.location_permission_title), + style = SRTheme.typography.bodyXMM, + color = SRTheme.colors.textPrimary, + ) + Text( + text = stringResource(R.string.location_permission_message), + style = SRTheme.typography.bodySR, + color = SRTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(Spacing.space8)) + Button( + onClick = onConfirm, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors(containerColor = SRTheme.colors.blue50), + ) { + Text( + text = stringResource(R.string.location_permission_go_to_settings), + style = SRTheme.typography.bodyMSB, + color = SRTheme.colors.white, + modifier = Modifier.padding(vertical = Spacing.space8), + ) + } + Button( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors(containerColor = SRTheme.colors.coolNeutral95), + elevation = null, + ) { + Text( + text = stringResource(R.string.alarm_setting_cancel), + style = SRTheme.typography.bodyMSB, + color = SRTheme.colors.textSecondary, + modifier = Modifier.padding(vertical = Spacing.space8), + ) + } + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun NotificationPermissionBottomSheet( @@ -260,33 +368,46 @@ private fun AlarmSettingScreen( CircularProgressIndicator(color = SRTheme.colors.blue50) } } else { - Row( + Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = Spacing.space20), - horizontalArrangement = Arrangement.spacedBy(Spacing.space4), - verticalAlignment = Alignment.CenterVertically, + .padding(horizontal = Spacing.space20, vertical = Spacing.space12), + verticalArrangement = Arrangement.spacedBy(Spacing.space8), ) { - Icon( - modifier = Modifier.size(20.dp), - imageVector = ImageVector.vectorResource(DesignR.drawable.ic_bus), - contentDescription = null, - tint = SRTheme.colors.blue50, - ) + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Spacing.space4), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = ImageVector.vectorResource(DesignR.drawable.ic_bus), + contentDescription = null, + tint = SRTheme.colors.blue50, + ) + Text( + text = state.routeNo, + style = SRTheme.typography.bodySR, + color = SRTheme.colors.blue50, + ) + } + + if (state.boardingNodeName.isNotBlank()) { + Text( + text = stringResource(R.string.alarm_boarding_node_label, state.boardingNodeName), + style = SRTheme.typography.bodySR, + color = SRTheme.colors.textPrimary, + ) + } + Text( - text = state.routeNo, + text = stringResource(R.string.alarm_setting_select_stop_hint), style = SRTheme.typography.bodySR, - color = SRTheme.colors.blue50, + color = SRTheme.colors.textSecondary, ) } - Text( - text = stringResource(R.string.alarm_setting_select_stop_hint), - style = SRTheme.typography.bodySR, - color = SRTheme.colors.textSecondary, - modifier = Modifier.padding(horizontal = Spacing.space20, vertical = Spacing.space8), - ) - LazyColumn(modifier = Modifier.fillMaxSize()) { itemsIndexed(state.nodes, key = { _, node -> node.nodeId }) { index, node -> Box { diff --git a/feature/home/src/main/java/com/choiminjun/home/alarmsetting/AlarmSettingViewModel.kt b/feature/alarm/src/main/java/com/choiminjun/alarm/alarmsetting/AlarmSettingViewModel.kt similarity index 74% rename from feature/home/src/main/java/com/choiminjun/home/alarmsetting/AlarmSettingViewModel.kt rename to feature/alarm/src/main/java/com/choiminjun/alarm/alarmsetting/AlarmSettingViewModel.kt index 4fe2b84..7444bec 100644 --- a/feature/home/src/main/java/com/choiminjun/home/alarmsetting/AlarmSettingViewModel.kt +++ b/feature/alarm/src/main/java/com/choiminjun/alarm/alarmsetting/AlarmSettingViewModel.kt @@ -1,4 +1,4 @@ -package com.choiminjun.home.alarmsetting +package com.choiminjun.alarm.alarmsetting import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope @@ -7,9 +7,8 @@ import com.choiminjun.base.BaseViewModel import com.choiminjun.common.util.suspendRunCatching import com.choiminjun.domain.model.alarm.AlarmInfo import com.choiminjun.domain.model.bus.BusNode -import com.choiminjun.domain.model.bus.CityCode import com.choiminjun.domain.repository.AlarmRepository -import com.choiminjun.domain.repository.BusRepository +import com.choiminjun.domain.usecase.GetNodesByRouteUseCase import com.choiminjun.navigation.HomeGraph import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch @@ -18,14 +17,21 @@ import javax.inject.Inject @HiltViewModel class AlarmSettingViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - private val busRepository: BusRepository, + private val getNodesByRoute: GetNodesByRouteUseCase, private val alarmRepository: AlarmRepository, ) : BaseViewModel( initialState = AlarmSettingState(), ) { init { val route = savedStateHandle.toRoute() - reduce { copy(routeNo = route.routeNo, routeId = route.routeId) } + reduce { + AlarmSettingState( + routeNo = route.routeNo, + routeId = route.routeId, + boardingNodeId = route.boardingNodeId, + boardingNodeName = route.boardingNodeName, + ) + } loadNodes(route.routeId) } @@ -57,6 +63,8 @@ class AlarmSettingViewModel @Inject constructor( routeNo = state.value.routeNo, destNodeId = node.nodeId, destNodeName = node.nodeName, + boardingNodeId = state.value.boardingNodeId, + boardingNodeName = state.value.boardingNodeName, stopsBeforeAlarm = state.value.selectedStopsBefore, ), ) @@ -73,8 +81,10 @@ class AlarmSettingViewModel @Inject constructor( private fun loadNodes(routeId: String) = viewModelScope.launch { reduce { copy(isLoading = true) } - val nodes = suspendRunCatching { busRepository.getNodesByRoute(CityCode.BUSAN, routeId) } + val nodes = suspendRunCatching { getNodesByRoute(routeId) } .getOrElse { emptyList() } - reduce { copy(isLoading = false, nodes = nodes) } + val boardingIndex = nodes.indexOfFirst { it.nodeId == state.value.boardingNodeId } + val filteredNodes = if (boardingIndex != -1) nodes.drop(boardingIndex) else nodes + reduce { copy(isLoading = false, nodes = filteredNodes) } } } diff --git a/feature/alarm/src/main/java/com/choiminjun/alarm/navigation/AlarmNavigation.kt b/feature/alarm/src/main/java/com/choiminjun/alarm/navigation/AlarmNavigation.kt new file mode 100644 index 0000000..2ead6ff --- /dev/null +++ b/feature/alarm/src/main/java/com/choiminjun/alarm/navigation/AlarmNavigation.kt @@ -0,0 +1,46 @@ +package com.choiminjun.alarm.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.choiminjun.alarm.alarmmonitor.AlarmMonitorRoute +import com.choiminjun.alarm.alarmring.AlarmRingRoute +import com.choiminjun.alarm.alarmsetting.AlarmSettingRoute +import com.choiminjun.navigation.HomeGraph + +fun NavGraphBuilder.alarmGraph( + navigateBack: () -> Unit, + onAlarmSet: () -> Unit, + onAlarmStopped: () -> Unit, + navigateToAlarmRing: () -> Unit, + onAlarmDismissed: () -> Unit, + startAlarmService: (routeNo: String, nodeName: String) -> Unit, + stopAlarmService: () -> Unit, +) { + composable { + AlarmSettingRoute( + onBackClick = navigateBack, + onAlarmSet = { routeNo, nodeName -> + startAlarmService(routeNo, nodeName) + onAlarmSet() + }, + ) + } + composable { + AlarmMonitorRoute( + onBackClick = navigateBack, + onAlarmStopped = { + stopAlarmService() + onAlarmStopped() + }, + navigateToAlarmRing = navigateToAlarmRing, + ) + } + composable { + AlarmRingRoute( + onDismiss = { + stopAlarmService() + onAlarmDismissed() + }, + ) + } +} diff --git a/feature/alarm/src/main/res/values/strings.xml b/feature/alarm/src/main/res/values/strings.xml new file mode 100644 index 0000000..73783f4 --- /dev/null +++ b/feature/alarm/src/main/res/values/strings.xml @@ -0,0 +1,28 @@ + + 뒤로가기 + 하차 알림 설정 + 하차할 정류장을 선택하세요 + 하차 알림을 설정할까요? + %1$s번 · %2$s + 알림 설정하기 + 취소 + %d 정거장 전 + 승차역: %s + 알림 종료 + 현재 위치 파악 중.. + 현재 정류장: %s + %d 정류장 남았습니다 + 승차 + 하차 + 목적지에 곧 도착합니다 + 알림 끄기 + 하차 알림 + 하차 알림 진행 중 + %1$s번 · %2$s 하차 예정 + 알림 권한이 필요해요 + 하차 알림을 받으려면 알림 권한이 필요합니다.\n설정에서 알림을 허용해 주세요. + 설정으로 이동 + 위치 권한이 필요해요 + 하차 알림을 사용하려면 위치 권한이 필요합니다.\n설정에서 위치 권한을 허용해 주세요. + 설정으로 이동 + diff --git a/feature/home/src/main/AndroidManifest.xml b/feature/home/src/main/AndroidManifest.xml index 107ed5c..9e8a98b 100644 --- a/feature/home/src/main/AndroidManifest.xml +++ b/feature/home/src/main/AndroidManifest.xml @@ -1,11 +1,5 @@ - - - diff --git a/feature/home/src/main/java/com/choiminjun/home/home/HomeContract.kt b/feature/home/src/main/java/com/choiminjun/home/home/HomeContract.kt index 048839a..8c4cb80 100644 --- a/feature/home/src/main/java/com/choiminjun/home/home/HomeContract.kt +++ b/feature/home/src/main/java/com/choiminjun/home/home/HomeContract.kt @@ -36,25 +36,13 @@ sealed interface HomeIntent : UiIntent { data class DeleteRecentRouteSearch(val id: Long) : HomeIntent data class DeleteRecentNodeSearch(val id: Long) : HomeIntent data object ClickAlarmBanner : HomeIntent - data object CancelAlarm : HomeIntent data class ClickFavoriteRoute(val busRoute: BusRoute) : HomeIntent data class ClickFavoriteNode(val busNode: BusNode) : HomeIntent } sealed interface HomeSideEffect : UiSideEffect { - data class NavigateToBusRoute( - val routeId: String, - val routeNo: String, - val routeType: String, - val startNodeName: String, - val endNodeName: String, - val cityCode: String, - ) : HomeSideEffect - data class NavigateToBusNode( - val nodeId: String, - val nodeName: String, - val nodeNo: String?, - val cityCode: String, - ) : HomeSideEffect + data class NavigateToBusRoute(val busRoute: BusRoute) : HomeSideEffect + data class NavigateToBusNode(val busNode: BusNode) : HomeSideEffect + data object NavigateToAlarmMonitor : HomeSideEffect data object NavigateToAlarmRing : HomeSideEffect } diff --git a/feature/home/src/main/java/com/choiminjun/home/home/HomeScreen.kt b/feature/home/src/main/java/com/choiminjun/home/home/HomeScreen.kt index 6730387..3e8e9d0 100644 --- a/feature/home/src/main/java/com/choiminjun/home/home/HomeScreen.kt +++ b/feature/home/src/main/java/com/choiminjun/home/home/HomeScreen.kt @@ -40,29 +40,18 @@ import com.choiminjun.designsystem.R as DesignSystemR @Composable internal fun HomeRoute( viewModel: HomeViewModel = hiltViewModel(), - navigateToBusRoute: - (routeId: String, routeNo: String, routeType: String, startNodeName: String, endNodeName: String, cityCode: String) -> Unit, - navigateToBusNode: (nodeId: String, nodeName: String, nodeNo: String?, cityCode: String) -> Unit, + navigateToBusRoute: (BusRoute) -> Unit, + navigateToBusNode: (BusNode) -> Unit, + navigateToAlarmMonitor: () -> Unit, navigateToAlarmRing: () -> Unit, ) { val state by viewModel.collectAsState() viewModel.collectSideEffect { effect -> when (effect) { - is HomeSideEffect.NavigateToBusRoute -> navigateToBusRoute( - effect.routeId, - effect.routeNo, - effect.routeType, - effect.startNodeName, - effect.endNodeName, - effect.cityCode, - ) - is HomeSideEffect.NavigateToBusNode -> navigateToBusNode( - effect.nodeId, - effect.nodeName, - effect.nodeNo, - effect.cityCode, - ) + is HomeSideEffect.NavigateToBusRoute -> navigateToBusRoute(effect.busRoute) + is HomeSideEffect.NavigateToBusNode -> navigateToBusNode(effect.busNode) + HomeSideEffect.NavigateToAlarmMonitor -> navigateToAlarmMonitor() HomeSideEffect.NavigateToAlarmRing -> navigateToAlarmRing() } } @@ -79,7 +68,6 @@ internal fun HomeRoute( onRecentRouteSearchDelete = { id -> viewModel.onIntent(HomeIntent.DeleteRecentRouteSearch(id)) }, onRecentNodeSearchDelete = { id -> viewModel.onIntent(HomeIntent.DeleteRecentNodeSearch(id)) }, onAlarmBannerClick = { viewModel.onIntent(HomeIntent.ClickAlarmBanner) }, - onAlarmCancelClick = { viewModel.onIntent(HomeIntent.CancelAlarm) }, onFavoriteRouteClick = { busRoute -> viewModel.onIntent(HomeIntent.ClickFavoriteRoute(busRoute)) }, onFavoriteNodeClick = { busNode -> viewModel.onIntent(HomeIntent.ClickFavoriteNode(busNode)) }, ) @@ -99,7 +87,6 @@ private fun HomeScreen( onRecentRouteSearchDelete: (Long) -> Unit, onRecentNodeSearchDelete: (Long) -> Unit, onAlarmBannerClick: () -> Unit, - onAlarmCancelClick: () -> Unit, onFavoriteRouteClick: (BusRoute) -> Unit, onFavoriteNodeClick: (BusNode) -> Unit, ) { @@ -167,8 +154,6 @@ private fun HomeScreen( ) } else { state.alarmInfo?.let { alarmInfo -> - // FIXME: 현재는 배너 클릭 시 AlarmRingScreen으로 진입. - // 실제로는 버스가 목적지에 근접할 때 알람 이벤트로 강제 전환해야 함. Column( modifier = Modifier .fillMaxWidth() @@ -179,7 +164,6 @@ private fun HomeScreen( destNodeName = alarmInfo.destNodeName, stopsBeforeAlarm = alarmInfo.stopsBeforeAlarm, onClick = onAlarmBannerClick, - onCancelClick = onAlarmCancelClick, ) } } @@ -225,7 +209,6 @@ private fun HomeScreenPreview() { onRecentRouteSearchDelete = {}, onRecentNodeSearchDelete = {}, onAlarmBannerClick = {}, - onAlarmCancelClick = {}, onFavoriteRouteClick = {}, onFavoriteNodeClick = {}, ) @@ -250,7 +233,6 @@ private fun HomeScreenPrevSearchPreview() { onRecentRouteSearchDelete = {}, onRecentNodeSearchDelete = {}, onAlarmBannerClick = {}, - onAlarmCancelClick = {}, onFavoriteRouteClick = {}, onFavoriteNodeClick = {}, ) @@ -295,7 +277,6 @@ private fun HomeScreenSearchBusTabPreview() { onRecentRouteSearchDelete = {}, onRecentNodeSearchDelete = {}, onAlarmBannerClick = {}, - onAlarmCancelClick = {}, onFavoriteRouteClick = {}, onFavoriteNodeClick = {}, ) @@ -338,7 +319,6 @@ private fun HomeScreenSearchStopTabPreview() { onRecentRouteSearchDelete = {}, onRecentNodeSearchDelete = {}, onAlarmBannerClick = {}, - onAlarmCancelClick = {}, onFavoriteRouteClick = {}, onFavoriteNodeClick = {}, ) diff --git a/feature/home/src/main/java/com/choiminjun/home/home/HomeViewModel.kt b/feature/home/src/main/java/com/choiminjun/home/home/HomeViewModel.kt index eff47d9..1c2f729 100644 --- a/feature/home/src/main/java/com/choiminjun/home/home/HomeViewModel.kt +++ b/feature/home/src/main/java/com/choiminjun/home/home/HomeViewModel.kt @@ -1,6 +1,5 @@ package com.choiminjun.home.home -import android.util.Log import androidx.lifecycle.viewModelScope import com.choiminjun.base.BaseViewModel import com.choiminjun.common.util.suspendRunCatching @@ -35,6 +34,11 @@ class HomeViewModel @Inject constructor( private fun observeAlarm() { viewModelScope.launch { alarmRepository.observeAlarm().collect { alarmInfo -> + if (alarmInfo.isTriggered) { + postSideEffect(HomeSideEffect.NavigateToAlarmRing) + return@collect + } + reduce { copy(alarmInfo = alarmInfo.takeIf { it.routeId.isNotBlank() }) } } } @@ -46,6 +50,7 @@ class HomeViewModel @Inject constructor( reduce { copy(favoriteRoutes = routes) } } } + viewModelScope.launch { favoriteRepository.getFavoriteNodes().collect { nodes -> reduce { copy(favoriteNodes = nodes) } @@ -64,8 +69,7 @@ class HomeViewModel @Inject constructor( is HomeIntent.SelectTab -> selectTab(intent) is HomeIntent.DeleteRecentRouteSearch -> deleteRecentRouteSearch(intent.id) is HomeIntent.DeleteRecentNodeSearch -> deleteRecentNodeSearch(intent.id) - HomeIntent.ClickAlarmBanner -> postSideEffect(HomeSideEffect.NavigateToAlarmRing) - HomeIntent.CancelAlarm -> cancelAlarm() + HomeIntent.ClickAlarmBanner -> postSideEffect(HomeSideEffect.NavigateToAlarmMonitor) is HomeIntent.ClickFavoriteRoute -> clickBusRoute(intent.busRoute) is HomeIntent.ClickFavoriteNode -> clickBusNode(intent.busNode) } @@ -73,28 +77,12 @@ class HomeViewModel @Inject constructor( private fun clickBusRoute(busRoute: BusRoute) { saveRecentRoute(busRoute) - postSideEffect( - HomeSideEffect.NavigateToBusRoute( - busRoute.routeId, - busRoute.routeNo, - busRoute.routeType, - busRoute.startNodeName, - busRoute.endNodeName, - busRoute.cityCode.name, - ), - ) + postSideEffect(HomeSideEffect.NavigateToBusRoute(busRoute)) } private fun clickBusNode(busNode: BusNode) { saveRecentNode(busNode) - postSideEffect( - HomeSideEffect.NavigateToBusNode( - busNode.nodeId, - busNode.nodeName, - busNode.nodeNo, - busNode.cityCode.name, - ), - ) + postSideEffect(HomeSideEffect.NavigateToBusNode(busNode)) } private fun selectTab(intent: HomeIntent.SelectTab) { @@ -154,13 +142,11 @@ class HomeViewModel @Inject constructor( val routesJob = launch { val result = suspendRunCatching { busRepository.getRouteNumbers(CityCode.BUSAN, query) } - if (result.isFailure) Log.e("SearchError", "노선 실패: ${result.exceptionOrNull()?.message}") reduce { copy(searchedRoutes = result.getOrElse { emptyList() }) } } val nodesJob = launch { val result = suspendRunCatching { busRepository.getNodeNumbers(CityCode.BUSAN, query) } - if (result.isFailure) Log.e("SearchError", "정류장 실패: ${result.exceptionOrNull()?.message}") reduce { copy(searchedNodes = result.getOrElse { emptyList() }) } } @@ -192,10 +178,6 @@ class HomeViewModel @Inject constructor( suspendRunCatching { recentSearchRepository.deleteNode(id) } } - private fun cancelAlarm() = viewModelScope.launch { - suspendRunCatching { alarmRepository.clearAlarm() } - } - private fun loadRecentSearches() { viewModelScope.launch { recentSearchRepository.getRecentRouteSearches().collect { searches -> diff --git a/feature/home/src/main/java/com/choiminjun/home/home/component/BoardingBanner.kt b/feature/home/src/main/java/com/choiminjun/home/home/component/BoardingBanner.kt index c148505..8753a18 100644 --- a/feature/home/src/main/java/com/choiminjun/home/home/component/BoardingBanner.kt +++ b/feature/home/src/main/java/com/choiminjun/home/home/component/BoardingBanner.kt @@ -2,8 +2,6 @@ package com.choiminjun.home.home.component import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -14,22 +12,17 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.choiminjun.designsystem.theme.SRTheme import com.choiminjun.designsystem.theme.Spacing import com.choiminjun.home.R -import com.choiminjun.designsystem.R as DesignR @Composable internal fun BoardingBanner( @@ -37,7 +30,6 @@ internal fun BoardingBanner( destNodeName: String, stopsBeforeAlarm: Int, onClick: () -> Unit, - onCancelClick: () -> Unit, ) { Column( modifier = Modifier @@ -50,7 +42,7 @@ internal fun BoardingBanner( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { - Box( + androidx.compose.foundation.layout.Box( modifier = Modifier .size(8.dp) .background(Color(0xFF34C759), CircleShape), @@ -60,32 +52,7 @@ internal fun BoardingBanner( text = stringResource(R.string.alarm_banner_status), style = SRTheme.typography.bodyXSSB, color = Color.White, - modifier = Modifier.weight(1f), ) - Box( - modifier = Modifier - .clip(CircleShape) - .background(SRTheme.colors.blue70) - .padding(vertical = Spacing.space8, horizontal = Spacing.space12) - .clickable { onCancelClick() }, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - modifier = Modifier.size(16.dp), - imageVector = ImageVector.vectorResource(DesignR.drawable.ic_close), - contentDescription = null, - tint = Color.White, - ) - Text( - text = stringResource(R.string.alarm_cancel_button), - style = SRTheme.typography.bodyXSSB, - color = Color.White, - ) - } - } } Spacer(Modifier.height(Spacing.space16)) Text( @@ -111,7 +78,6 @@ private fun BoardingBannerPreview() { destNodeName = "하단", stopsBeforeAlarm = 3, onClick = {}, - onCancelClick = {}, ) } } diff --git a/feature/home/src/main/java/com/choiminjun/home/navigation/HomeNavigation.kt b/feature/home/src/main/java/com/choiminjun/home/navigation/HomeNavigation.kt index f081c08..0e7edf4 100644 --- a/feature/home/src/main/java/com/choiminjun/home/navigation/HomeNavigation.kt +++ b/feature/home/src/main/java/com/choiminjun/home/navigation/HomeNavigation.kt @@ -5,8 +5,8 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable import androidx.navigation.navigation -import com.choiminjun.home.alarmring.AlarmRingRoute -import com.choiminjun.home.alarmsetting.AlarmSettingRoute +import com.choiminjun.domain.model.bus.BusNode +import com.choiminjun.domain.model.bus.BusRoute import com.choiminjun.home.home.HomeRoute import com.choiminjun.home.node.BusNodeRoute import com.choiminjun.home.route.BusRouteRoute @@ -18,12 +18,13 @@ fun NavController.navigateToHome(navOptions: NavOptions? = null) { } fun NavGraphBuilder.homeGraph( - navigateToBusRoute: - (routeId: String, routeNo: String, routeType: String, startNodeName: String, endNodeName: String, cityCode: String) -> Unit, - navigateToBusNode: (nodeId: String, nodeName: String, nodeNo: String?, cityCode: String) -> Unit, + navigateToBusRoute: (BusRoute) -> Unit, + navigateToBusNode: (BusNode) -> Unit, navigateBack: () -> Unit, navigateToHome: () -> Unit, - navigateToAlarmSetting: (routeId: String, routeNo: String) -> Unit, + navigateToAlarmSetting: + (routeId: String, routeNo: String, boardingNodeId: String, boardingNodeName: String) -> Unit, + navigateToAlarmMonitor: () -> Unit, navigateToAlarmRing: () -> Unit, ) { navigation(startDestination = HomeGraph.HomeRoute) { @@ -31,6 +32,7 @@ fun NavGraphBuilder.homeGraph( HomeRoute( navigateToBusRoute = navigateToBusRoute, navigateToBusNode = navigateToBusNode, + navigateToAlarmMonitor = navigateToAlarmMonitor, navigateToAlarmRing = navigateToAlarmRing, ) } @@ -44,21 +46,12 @@ fun NavGraphBuilder.homeGraph( composable { BusNodeRoute( onBackClick = navigateBack, - onAlarmClick = navigateToAlarmSetting, + onAlarmClick = { routeId, routeNo, boardingNodeId, boardingNodeName -> + navigateToAlarmSetting(routeId, routeNo, boardingNodeId, boardingNodeName) + }, navigateToBusRoute = navigateToBusRoute, navigateToHome = navigateToHome, ) } - composable { - AlarmSettingRoute( - onBackClick = navigateBack, - onAlarmSet = { _, _ -> navigateToHome() }, - ) - } - composable { - AlarmRingRoute( - onDismiss = navigateToHome, - ) - } } } diff --git a/feature/home/src/main/java/com/choiminjun/home/node/BusNodeContract.kt b/feature/home/src/main/java/com/choiminjun/home/node/BusNodeContract.kt index fa3ffed..35f8900 100644 --- a/feature/home/src/main/java/com/choiminjun/home/node/BusNodeContract.kt +++ b/feature/home/src/main/java/com/choiminjun/home/node/BusNodeContract.kt @@ -3,13 +3,11 @@ package com.choiminjun.home.node import com.choiminjun.base.UiIntent import com.choiminjun.base.UiSideEffect import com.choiminjun.base.UiState +import com.choiminjun.domain.model.bus.BusNode import com.choiminjun.domain.model.bus.BusRoute data class BusNodeState( - val nodeId: String = "", - val nodeName: String = "", - val nodeNo: String? = null, - val cityCode: String = "", + val busNode: BusNode? = null, val isLoading: Boolean = false, val routes: List = emptyList(), val isFavorite: Boolean = false, @@ -17,21 +15,19 @@ data class BusNodeState( sealed interface BusNodeIntent : UiIntent { data object ClickBack : BusNodeIntent - data class ClickAlarm(val routeId: String, val routeNo: String) : BusNodeIntent + data class ClickAlarm(val route: BusRoute) : BusNodeIntent data class ClickBusRoute(val route: BusRoute) : BusNodeIntent data object ToggleFavorite : BusNodeIntent } sealed interface BusNodeSideEffect : UiSideEffect { data object NavigateBack : BusNodeSideEffect - data class NavigateToAlarm(val routeId: String, val routeNo: String) : BusNodeSideEffect - data class NavigateToBusRoute( + data class NavigateToAlarm( val routeId: String, val routeNo: String, - val routeType: String, - val startNodeName: String, - val endNodeName: String, - val cityCode: String, + val boardingNodeId: String, + val boardingNodeName: String, ) : BusNodeSideEffect + data class NavigateToBusRoute(val busRoute: BusRoute) : BusNodeSideEffect data class ShowSnackbar(val added: Boolean) : BusNodeSideEffect } diff --git a/feature/home/src/main/java/com/choiminjun/home/node/BusNodeScreen.kt b/feature/home/src/main/java/com/choiminjun/home/node/BusNodeScreen.kt index c7d7af9..a44d5f5 100644 --- a/feature/home/src/main/java/com/choiminjun/home/node/BusNodeScreen.kt +++ b/feature/home/src/main/java/com/choiminjun/home/node/BusNodeScreen.kt @@ -1,6 +1,5 @@ package com.choiminjun.home.node -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -11,12 +10,14 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -39,6 +40,7 @@ import com.choiminjun.designsystem.component.SRIconButton import com.choiminjun.designsystem.component.SRSnackbar import com.choiminjun.designsystem.theme.SRTheme import com.choiminjun.designsystem.theme.Spacing +import com.choiminjun.domain.model.bus.BusNode import com.choiminjun.domain.model.bus.BusRoute import com.choiminjun.domain.model.bus.CityCode import kotlinx.coroutines.Job @@ -48,9 +50,8 @@ import com.choiminjun.home.R as HR @Composable internal fun BusNodeRoute( onBackClick: () -> Unit, - onAlarmClick: (routeId: String, routeNo: String) -> Unit, - navigateToBusRoute: - (routeId: String, routeNo: String, routeType: String, startNodeName: String, endNodeName: String, cityCode: String) -> Unit, + onAlarmClick: (routeId: String, routeNo: String, boardingNodeId: String, boardingNodeName: String) -> Unit, + navigateToBusRoute: (BusRoute) -> Unit, navigateToHome: () -> Unit, viewModel: BusNodeViewModel = hiltViewModel(), ) { @@ -64,15 +65,13 @@ internal fun BusNodeRoute( viewModel.collectSideEffect { effect -> when (effect) { BusNodeSideEffect.NavigateBack -> onBackClick() - is BusNodeSideEffect.NavigateToAlarm -> onAlarmClick(effect.routeId, effect.routeNo) - is BusNodeSideEffect.NavigateToBusRoute -> navigateToBusRoute( + is BusNodeSideEffect.NavigateToAlarm -> onAlarmClick( effect.routeId, effect.routeNo, - effect.routeType, - effect.startNodeName, - effect.endNodeName, - effect.cityCode, + effect.boardingNodeId, + effect.boardingNodeName, ) + is BusNodeSideEffect.NavigateToBusRoute -> navigateToBusRoute(effect.busRoute) is BusNodeSideEffect.ShowSnackbar -> { snackbarJob?.cancel() @@ -90,7 +89,7 @@ internal fun BusNodeRoute( state = state, snackbarHostState = snackbarHostState, onBackClick = { viewModel.onIntent(BusNodeIntent.ClickBack) }, - onAlarmClick = { routeId, routeNo -> viewModel.onIntent(BusNodeIntent.ClickAlarm(routeId, routeNo)) }, + onAlarmClick = { route -> viewModel.onIntent(BusNodeIntent.ClickAlarm(route)) }, onRouteClick = { route -> viewModel.onIntent(BusNodeIntent.ClickBusRoute(route)) }, onHomeClick = navigateToHome, onFavoriteClick = { viewModel.onIntent(BusNodeIntent.ToggleFavorite) }, @@ -102,7 +101,7 @@ private fun BusNodeScreen( state: BusNodeState, snackbarHostState: SnackbarHostState, onBackClick: () -> Unit, - onAlarmClick: (routeId: String, routeNo: String) -> Unit, + onAlarmClick: (BusRoute) -> Unit, onRouteClick: (BusRoute) -> Unit, onHomeClick: () -> Unit, onFavoriteClick: () -> Unit, @@ -126,7 +125,7 @@ private fun BusNodeScreen( onClick = { onBackClick() }, ) Text( - text = state.nodeName, + text = state.busNode?.nodeName ?: "", style = SRTheme.typography.bodyXMM, color = SRTheme.colors.textPrimary, modifier = Modifier.weight(1f), @@ -172,7 +171,7 @@ private fun BusNodeScreen( state = listState, ) { item { - state.nodeNo?.let { nodeNo -> + state.busNode?.nodeNo?.let { nodeNo -> Column( modifier = Modifier .fillMaxWidth() @@ -192,7 +191,7 @@ private fun BusNodeScreen( items(state.routes, key = { it.routeId }) { route -> BusNodeRouteItem( route = route, - onAlarmClick = { onAlarmClick(route.routeId, route.routeNo) }, + onAlarmClick = { onAlarmClick(route) }, onRouteClick = { onRouteClick(route) }, ) } @@ -225,11 +224,27 @@ private fun BusNodeRouteItem( style = SRTheme.typography.bodyMM, color = SRTheme.colors.blue50, ) - Text( - text = "${route.startNodeName} → ${route.endNodeName}", - style = SRTheme.typography.bodySR, - color = SRTheme.colors.textSecondary, - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Spacing.space4), + ) { + Text( + text = route.startNodeName, + style = SRTheme.typography.bodySR, + color = SRTheme.colors.textSecondary, + ) + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_horizontal_arrow), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = SRTheme.colors.textSecondary, + ) + Text( + text = route.endNodeName, + style = SRTheme.typography.bodySR, + color = SRTheme.colors.textSecondary, + ) + } } SRIconButton( imageVector = ImageVector.vectorResource(R.drawable.ic_bell), @@ -250,8 +265,7 @@ private fun BusNodeScreenPreview() { SRTheme { BusNodeScreen( state = BusNodeState( - nodeName = "부산대학교앞", - nodeNo = "12345", + busNode = BusNode("N000", "부산대학교앞", nodeNo = "12345", cityCode = CityCode.BUSAN), routes = listOf( BusRoute("R001", "51", "일반", "노포동", "하단", CityCode.BUSAN), BusRoute("R002", "179", "일반", "기장", "사상", CityCode.BUSAN), @@ -260,7 +274,7 @@ private fun BusNodeScreenPreview() { ), snackbarHostState = remember { SnackbarHostState() }, onBackClick = {}, - onAlarmClick = { _, _ -> }, + onAlarmClick = { }, onRouteClick = { }, onHomeClick = {}, onFavoriteClick = {}, diff --git a/feature/home/src/main/java/com/choiminjun/home/node/BusNodeViewModel.kt b/feature/home/src/main/java/com/choiminjun/home/node/BusNodeViewModel.kt index a03bd29..2280439 100644 --- a/feature/home/src/main/java/com/choiminjun/home/node/BusNodeViewModel.kt +++ b/feature/home/src/main/java/com/choiminjun/home/node/BusNodeViewModel.kt @@ -38,10 +38,12 @@ class BusNodeViewModel @Inject constructor( private fun initNode(node: HomeGraph.BusNodeRoute) { reduce { copy( - nodeId = node.nodeId, - nodeName = node.nodeName, - nodeNo = node.nodeNo, - cityCode = node.cityCode, + busNode = BusNode( + nodeId = node.nodeId, + nodeName = node.nodeName, + nodeNo = node.nodeNo, + cityCode = CityCode.valueOf(node.cityCode), + ), ) } } @@ -49,26 +51,29 @@ class BusNodeViewModel @Inject constructor( override suspend fun handleIntent(intent: BusNodeIntent) { when (intent) { BusNodeIntent.ClickBack -> postSideEffect(BusNodeSideEffect.NavigateBack) - is BusNodeIntent.ClickAlarm -> postSideEffect(BusNodeSideEffect.NavigateToAlarm(intent.routeId, intent.routeNo)) + is BusNodeIntent.ClickAlarm -> clickAlarm(intent.route) is BusNodeIntent.ClickBusRoute -> clickBusRoute(intent.route) BusNodeIntent.ToggleFavorite -> toggleFavorite() } } - private fun clickBusRoute(busRoute: BusRoute) { - saveRecentRoute(busRoute) + private fun clickAlarm(route: BusRoute) { + val node = state.value.busNode ?: return postSideEffect( - BusNodeSideEffect.NavigateToBusRoute( - busRoute.routeId, - busRoute.routeNo, - busRoute.routeType, - busRoute.startNodeName, - busRoute.endNodeName, - busRoute.cityCode.name, + BusNodeSideEffect.NavigateToAlarm( + routeId = route.routeId, + routeNo = route.routeNo, + boardingNodeId = node.nodeId, + boardingNodeName = node.nodeName, ), ) } + private fun clickBusRoute(busRoute: BusRoute) { + saveRecentRoute(busRoute) + postSideEffect(BusNodeSideEffect.NavigateToBusRoute(busRoute)) + } + private fun saveRecentRoute(busRoute: BusRoute) = viewModelScope.launch { suspendRunCatching { recentSearchRepository.saveRoute(busRoute) @@ -95,16 +100,10 @@ class BusNodeViewModel @Inject constructor( private fun toggleFavorite() { toggleFavoriteJob?.cancel() toggleFavoriteJob = viewModelScope.launch { + val currentNode = state.value.busNode ?: return@launch val willAdd = !state.value.isFavorite suspendRunCatching { - favoriteRepository.toggleFavoriteNode( - BusNode( - nodeId = state.value.nodeId, - nodeName = state.value.nodeName, - nodeNo = state.value.nodeNo, - cityCode = CityCode.valueOf(state.value.cityCode), - ), - ) + favoriteRepository.toggleFavoriteNode(currentNode) }.onSuccess { postSideEffect(BusNodeSideEffect.ShowSnackbar(added = willAdd)) } diff --git a/feature/home/src/main/java/com/choiminjun/home/route/BusRouteContract.kt b/feature/home/src/main/java/com/choiminjun/home/route/BusRouteContract.kt index d1c987b..fced248 100644 --- a/feature/home/src/main/java/com/choiminjun/home/route/BusRouteContract.kt +++ b/feature/home/src/main/java/com/choiminjun/home/route/BusRouteContract.kt @@ -4,14 +4,10 @@ import com.choiminjun.base.UiIntent import com.choiminjun.base.UiSideEffect import com.choiminjun.base.UiState import com.choiminjun.domain.model.bus.BusNode +import com.choiminjun.domain.model.bus.BusRoute data class BusRouteState( - val routeId: String = "", - val routeNo: String = "", - val routeType: String = "", - val startNodeName: String = "", - val endNodeName: String = "", - val cityCode: String = "", + val busRoute: BusRoute? = null, val isLoading: Boolean = false, val nodes: List = emptyList(), val isFavorite: Boolean = false, @@ -25,11 +21,6 @@ sealed interface BusRouteIntent : UiIntent { sealed interface BusRouteSideEffect : UiSideEffect { data object NavigateBack : BusRouteSideEffect - data class NavigateToBusNode( - val nodeId: String, - val nodeName: String, - val nodeNo: String?, - val cityCode: String, - ) : BusRouteSideEffect + data class NavigateToBusNode(val busNode: BusNode) : BusRouteSideEffect data class ShowSnackbar(val added: Boolean) : BusRouteSideEffect } diff --git a/feature/home/src/main/java/com/choiminjun/home/route/BusRouteScreen.kt b/feature/home/src/main/java/com/choiminjun/home/route/BusRouteScreen.kt index f23b40b..7850c18 100644 --- a/feature/home/src/main/java/com/choiminjun/home/route/BusRouteScreen.kt +++ b/feature/home/src/main/java/com/choiminjun/home/route/BusRouteScreen.kt @@ -1,6 +1,5 @@ package com.choiminjun.home.route -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -47,6 +46,7 @@ import com.choiminjun.designsystem.component.SRSnackbar import com.choiminjun.designsystem.theme.SRTheme import com.choiminjun.designsystem.theme.Spacing import com.choiminjun.domain.model.bus.BusNode +import com.choiminjun.domain.model.bus.BusRoute import com.choiminjun.domain.model.bus.CityCode import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -59,7 +59,7 @@ private val StopIconAreaWidth: Dp = Spacing.space20 + StopIconSize + Spacing.spa @Composable internal fun BusRouteRoute( onBackClick: () -> Unit, - navigateToBusNode: (nodeId: String, nodeName: String, nodeNo: String?, cityCode: String) -> Unit, + navigateToBusNode: (BusNode) -> Unit, navigateToHome: () -> Unit, viewModel: BusRouteViewModel = hiltViewModel(), ) { @@ -73,12 +73,7 @@ internal fun BusRouteRoute( viewModel.collectSideEffect { effect -> when (effect) { BusRouteSideEffect.NavigateBack -> onBackClick() - is BusRouteSideEffect.NavigateToBusNode -> navigateToBusNode( - effect.nodeId, - effect.nodeName, - effect.nodeNo, - effect.cityCode, - ) + is BusRouteSideEffect.NavigateToBusNode -> navigateToBusNode(effect.busNode) is BusRouteSideEffect.ShowSnackbar -> { snackbarJob?.cancel() @@ -130,7 +125,7 @@ private fun BusRouteScreen( onClick = { onBackClick() }, ) Text( - text = state.routeNo, + text = state.busRoute?.routeNo ?: "", style = SRTheme.typography.bodyXMM, color = SRTheme.colors.blue50, modifier = Modifier.weight(1f), @@ -179,8 +174,6 @@ private fun BusRouteScreen( state = listState, ) { item { - val firstNode = state.nodes.firstOrNull() - val lastNode = state.nodes.lastOrNull() Column( modifier = Modifier .fillMaxWidth() @@ -189,7 +182,7 @@ private fun BusRouteScreen( verticalArrangement = Arrangement.spacedBy(Spacing.space8), ) { Text( - text = firstNode?.cityCode?.label ?: "", + text = state.busRoute?.cityCode?.label ?: "", style = SRTheme.typography.bodyXMM, color = SRTheme.colors.textSecondary, ) @@ -198,18 +191,18 @@ private fun BusRouteScreen( horizontalArrangement = Arrangement.spacedBy(Spacing.space4), ) { Text( - text = firstNode?.nodeName ?: "", + text = state.busRoute?.startNodeName ?: "", style = SRTheme.typography.bodyMM, color = SRTheme.colors.textPrimary, ) Icon( imageVector = ImageVector.vectorResource(R.drawable.ic_horizontal_arrow), contentDescription = null, - modifier = Modifier.size(Spacing.space16), + modifier = Modifier.size(16.dp), tint = SRTheme.colors.textPrimary, ) Text( - text = lastNode?.nodeName ?: "", + text = state.busRoute?.endNodeName ?: "", style = SRTheme.typography.bodyMM, color = SRTheme.colors.textPrimary, ) @@ -331,7 +324,10 @@ private fun BusRouteScreenPreview() { ) SRTheme { BusRouteScreen( - state = BusRouteState(routeNo = "51", nodes = nodes), + state = BusRouteState( + busRoute = BusRoute("R001", "51", "일반", "노포동", "하단", CityCode.BUSAN), + nodes = nodes, + ), snackbarHostState = remember { SnackbarHostState() }, onBackClick = {}, onNodeClick = { }, diff --git a/feature/home/src/main/java/com/choiminjun/home/route/BusRouteViewModel.kt b/feature/home/src/main/java/com/choiminjun/home/route/BusRouteViewModel.kt index fbd25be..e33ac5b 100644 --- a/feature/home/src/main/java/com/choiminjun/home/route/BusRouteViewModel.kt +++ b/feature/home/src/main/java/com/choiminjun/home/route/BusRouteViewModel.kt @@ -8,9 +8,9 @@ import com.choiminjun.common.util.suspendRunCatching import com.choiminjun.domain.model.bus.BusNode import com.choiminjun.domain.model.bus.BusRoute import com.choiminjun.domain.model.bus.CityCode -import com.choiminjun.domain.repository.BusRepository import com.choiminjun.domain.repository.FavoriteRepository import com.choiminjun.domain.repository.RecentSearchRepository +import com.choiminjun.domain.usecase.GetNodesByRouteUseCase import com.choiminjun.navigation.HomeGraph import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job @@ -20,7 +20,7 @@ import javax.inject.Inject @HiltViewModel class BusRouteViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - private val busRepository: BusRepository, + private val getNodesByRoute: GetNodesByRouteUseCase, private val recentSearchRepository: RecentSearchRepository, private val favoriteRepository: FavoriteRepository, ) : BaseViewModel( @@ -38,12 +38,14 @@ class BusRouteViewModel @Inject constructor( private fun initRoute(route: HomeGraph.BusRouteRoute) { reduce { copy( - routeId = route.routeId, - routeNo = route.routeNo, - routeType = route.routeType, - startNodeName = route.startNodeName, - endNodeName = route.endNodeName, - cityCode = route.cityCode, + busRoute = BusRoute( + routeId = route.routeId, + routeNo = route.routeNo, + routeType = route.routeType, + startNodeName = route.startNodeName, + endNodeName = route.endNodeName, + cityCode = CityCode.valueOf(route.cityCode), + ), ) } } @@ -58,14 +60,7 @@ class BusRouteViewModel @Inject constructor( private fun clickBusNode(busNode: BusNode) { saveRecentNode(busNode) - postSideEffect( - BusRouteSideEffect.NavigateToBusNode( - busNode.nodeId, - busNode.nodeName, - busNode.nodeNo, - busNode.cityCode.name, - ), - ) + postSideEffect(BusRouteSideEffect.NavigateToBusNode(busNode)) } private fun saveRecentNode(busNode: BusNode) = viewModelScope.launch { @@ -85,18 +80,10 @@ class BusRouteViewModel @Inject constructor( private fun toggleFavorite() { toggleFavoriteJob?.cancel() toggleFavoriteJob = viewModelScope.launch { + val currentRoute = state.value.busRoute ?: return@launch val willAdd = !state.value.isFavorite suspendRunCatching { - favoriteRepository.toggleFavoriteRoute( - BusRoute( - routeId = state.value.routeId, - routeNo = state.value.routeNo, - routeType = state.value.routeType, - startNodeName = state.value.startNodeName, - endNodeName = state.value.endNodeName, - cityCode = CityCode.valueOf(state.value.cityCode), - ), - ) + favoriteRepository.toggleFavoriteRoute(currentRoute) }.onSuccess { postSideEffect(BusRouteSideEffect.ShowSnackbar(added = willAdd)) } @@ -106,7 +93,7 @@ class BusRouteViewModel @Inject constructor( private fun loadNodes(routeId: String) { viewModelScope.launch { reduce { copy(isLoading = true) } - val nodes = suspendRunCatching { busRepository.getNodesByRoute(CityCode.BUSAN, routeId) } + val nodes = suspendRunCatching { getNodesByRoute(routeId) } .getOrElse { emptyList() } reduce { copy(isLoading = false, nodes = nodes) } } diff --git a/feature/home/src/main/res/values/strings.xml b/feature/home/src/main/res/values/strings.xml index e36c597..9f3aabb 100644 --- a/feature/home/src/main/res/values/strings.xml +++ b/feature/home/src/main/res/values/strings.xml @@ -9,28 +9,9 @@ %1$s → %2$s 뒤로가기 메뉴 - 하차 알림 설정 - 하차할 정류장을 선택하세요 - 하차 알림을 설정할까요? - %1$s번 · %2$s - 알림 설정하기 - 취소 - %1$s번 탑승 중 · %2$s 하차 예정 알람 활성·탑승 중 - 알림 취소 %s번 → %1$s 하차 · %2$d정거장 전 알림 - 현재: %s - %d 정거장 전 - 목적지에 곧 도착합니다 - 알림 끄기 - alarm_channel - 하차 알림 - 하차 알림 진행 중 - %1$s번 · %2$s 하차 예정 - 알림 권한이 필요해요 - 하차 알림을 받으려면 알림 권한이 필요합니다.\n설정에서 알림을 허용해 주세요. - 설정으로 이동 즐겨찾기 즐겨찾기에 추가되었습니다. 즐겨찾기에서 삭제되었습니다. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 30bfb55..089fa05 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,6 +55,12 @@ detekt = "1.23.8" ktlint-gradle = "12.1.1" ktlint-source = "0.50.0" +## Google Play Services +playServicesLocation = "21.3.0" + +# Timber +timber = "5.0.1" + ## firebase firebaseBom = "34.12.0" googleServices = "4.4.4" @@ -136,11 +142,17 @@ androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchm androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" } androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } +# Google Play Services +play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } + # Firebase firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } +# Timber +timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } + # detekt detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-formatting", version.ref = "detekt" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 6fd168b..6a0b8fd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,3 +37,4 @@ include(":core:datastore") include(":feature") include(":feature:home") +include(":feature:alarm")