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")