diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6e74d8895..a5812d11b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -7,6 +7,7 @@ plugins {
alias(libs.plugins.googleServices)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
+ alias(libs.plugins.firebase.crashlytics)
}
android {
@@ -17,8 +18,8 @@ android {
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
- versionCode = 16
- versionName = "1.3.7"
+ versionCode = 17
+ versionName = "1.3.8"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -66,6 +67,9 @@ dependencies {
implementation(project(ProjectPaths.Core.PROJECT))
implementation(project(ProjectPaths.Core.SCHOOL))
implementation(project(ProjectPaths.Core.UI))
+ implementation(project(ProjectPaths.Core.NOTIFICATION))
+ implementation(project(ProjectPaths.Core.DEVICE))
+ implementation(project(ProjectPaths.Core.WIDGET))
implementation(project(ProjectPaths.DATA))
implementation(project(ProjectPaths.DATABASE))
@@ -123,4 +127,14 @@ dependencies {
androidTestImplementation(libs.androidx.junit)
implementation(libs.app.update)
+
+ implementation(platform(libs.firebase.bom))
+ implementation(libs.firebase.messaging)
+ implementation(libs.firebase.analytics)
+ implementation(libs.firebase.crashlytics)
+
+ implementation(libs.androidx.work.runtime.ktx)
+
+ implementation(libs.androidx.hilt.work)
+ ksp(libs.androidx.hilt.compiler)
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index de07b68d2..b072d78bc 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -38,5 +38,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/team/aliens/dms/android/app/DmsApplication.kt b/app/src/main/java/team/aliens/dms/android/app/DmsApplication.kt
index 31b2ca8d8..5ff142893 100644
--- a/app/src/main/java/team/aliens/dms/android/app/DmsApplication.kt
+++ b/app/src/main/java/team/aliens/dms/android/app/DmsApplication.kt
@@ -1,7 +1,19 @@
package team.aliens.dms.android.app
import android.app.Application
+import androidx.hilt.work.HiltWorkerFactory
+import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
+import javax.inject.Inject
@HiltAndroidApp
-class DmsApplication : Application()
+class DmsApplication : Application(), Configuration.Provider {
+
+ @Inject
+ lateinit var workFactory: HiltWorkerFactory
+
+ override val workManagerConfiguration: Configuration
+ get() = Configuration.Builder()
+ .setWorkerFactory(workFactory)
+ .build()
+}
diff --git a/app/src/main/java/team/aliens/dms/android/app/MainActivity.kt b/app/src/main/java/team/aliens/dms/android/app/MainActivity.kt
index 90d69ce6d..b1559ce1d 100644
--- a/app/src/main/java/team/aliens/dms/android/app/MainActivity.kt
+++ b/app/src/main/java/team/aliens/dms/android/app/MainActivity.kt
@@ -7,14 +7,19 @@ import androidx.activity.compose.setContent
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.core.view.WindowCompat
+import androidx.lifecycle.lifecycleScope
import com.google.accompanist.adaptive.calculateDisplayFeatures
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
import com.google.android.play.core.install.model.AppUpdateType
import com.google.android.play.core.install.model.UpdateAvailability
import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
import team.aliens.dms.android.core.designsystem.DmsTheme
import team.aliens.dms.android.core.jwt.di.IsJwtAvailable
+import team.aliens.dms.android.core.notification.DeviceTokenManager
import javax.inject.Inject
@AndroidEntryPoint
@@ -24,6 +29,9 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var isJwtAvailable: StateFlow
+ @Inject
+ lateinit var deviceTokenManager: DeviceTokenManager
+
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -46,6 +54,10 @@ class MainActivity : ComponentActivity() {
)
}
}
+
+ lifecycleScope.launch {
+ deviceTokenManager.fetchDeviceToken()
+ }
}
private fun checkAppUpdate() {
diff --git a/app/src/main/java/team/aliens/dms/android/app/NotificationManager.kt b/app/src/main/java/team/aliens/dms/android/app/NotificationManager.kt
new file mode 100644
index 000000000..b4ac4e02d
--- /dev/null
+++ b/app/src/main/java/team/aliens/dms/android/app/NotificationManager.kt
@@ -0,0 +1,83 @@
+package team.aliens.dms.android.app
+
+import android.annotation.SuppressLint
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import team.aliens.dms.android.core.designsystem.DmsIcon
+import team.aliens.dms.android.core.notification.notificationPermissionGranted
+
+private object Notifications {
+ const val NOTIFICATION_ID = 0
+ const val NOTIFICATION_CHANNEL_ID = "dms"
+ const val CHANNEL_NAME = "dms"
+ const val CHANNEL_DESCRIPTION = "dms notification channel"
+}
+
+class NotificationManager(
+ private val context: Context,
+) {
+
+ init {
+ createNotificationChannel()
+ }
+
+ private val notificationManagerCompat: NotificationManagerCompat by lazy {
+ NotificationManagerCompat.from(context)
+ }
+
+ private val messageId = System.currentTimeMillis().toInt()
+ private val intent = Intent(context, MainActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ private val pendingIntent = PendingIntent.getActivity(
+ context, messageId, intent, PendingIntent.FLAG_IMMUTABLE
+ )
+
+ private val notificationBuilder: NotificationCompat.Builder by lazy {
+ NotificationCompat.Builder(context, Notifications.NOTIFICATION_CHANNEL_ID)
+ .setSmallIcon(DmsIcon.Notification)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setContentIntent(pendingIntent)
+ }
+
+ fun setNotificationContent(
+ title: String?,
+ body: String?,
+ ) {
+ notificationBuilder.run {
+ title?.run { setContentTitle(this) }
+ body?.run { setContentText(this) }
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ fun sendNotification() {
+ if (notificationPermissionGranted(context = context)) {
+ notificationManagerCompat.notify(
+ Notifications.NOTIFICATION_ID,
+ notificationBuilder.build(),
+ )
+ }
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val importance = NotificationManager.IMPORTANCE_DEFAULT
+ val channel = NotificationChannel(
+ Notifications.NOTIFICATION_CHANNEL_ID,
+ Notifications.CHANNEL_NAME,
+ importance
+ ).apply {
+ this.description = Notifications.CHANNEL_DESCRIPTION
+ }
+ val notificationManager =
+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ notificationManager.createNotificationChannel(channel)
+ }
+ }
+}
diff --git a/app/src/main/java/team/aliens/dms/android/app/navigation/DmsNavigator.kt b/app/src/main/java/team/aliens/dms/android/app/navigation/DmsNavigator.kt
index 17d91d62d..d15902574 100644
--- a/app/src/main/java/team/aliens/dms/android/app/navigation/DmsNavigator.kt
+++ b/app/src/main/java/team/aliens/dms/android/app/navigation/DmsNavigator.kt
@@ -22,6 +22,7 @@ import team.aliens.dms.android.feature.destinations.FindIdScreenDestination
import team.aliens.dms.android.feature.destinations.MainDestination
import team.aliens.dms.android.feature.destinations.NoticeDetailsScreenDestination
import team.aliens.dms.android.feature.destinations.NotificationBoxScreenDestination
+import team.aliens.dms.android.feature.destinations.NotificationSettingsScreenDestination
import team.aliens.dms.android.feature.destinations.OutingApplicationScreenDestination
import team.aliens.dms.android.feature.destinations.PointHistoryScreenDestination
import team.aliens.dms.android.feature.destinations.RemainsApplicationScreenDestination
@@ -36,6 +37,7 @@ import team.aliens.dms.android.feature.destinations.StudyRoomDetailsScreenDestin
import team.aliens.dms.android.feature.destinations.StudyRoomListScreenDestination
import team.aliens.dms.android.feature.destinations.TermsScreenDestination
import team.aliens.dms.android.feature.editpassword.navigation.EditPasswordNavGraph
+import team.aliens.dms.android.feature.notification.navigation.NotificationSettingsNavigator
import team.aliens.dms.android.feature.outing.navigation.OutingNavGraph
import team.aliens.dms.android.feature.resetpassword.navigation.ResetPasswordNavGraph
import team.aliens.dms.android.feature.signup.navigation.SignUpNavGraph
@@ -46,10 +48,6 @@ class DmsNavigator(
private val navController: NavController,
) : AuthorizedNavigator, UnauthorizedNavigator {
- override fun openNotificationBox() {
- navController.navigateSingleTop(NotificationBoxScreenDestination within navGraph)
- }
-
override fun openUnauthorizedNav() {
navController.navigateSingleTop(UnauthorizedNavGraph) {
popUpTo(AuthorizedNavGraph) {
@@ -58,6 +56,14 @@ class DmsNavigator(
}
}
+ override fun openSettingsNotification() {
+ navController.navigateSingleTop(NotificationSettingsScreenDestination within navGraph)
+ }
+
+ override fun openNotificationBox() {
+ navController.navigateSingleTop(NotificationBoxScreenDestination within navGraph)
+ }
+
override fun openStudyRoomList() {
navController.navigateSingleTop(StudyRoomListScreenDestination within navGraph)
}
diff --git a/app/src/main/java/team/aliens/dms/android/app/navigation/authorized/AuthorizedNavGraph.kt b/app/src/main/java/team/aliens/dms/android/app/navigation/authorized/AuthorizedNavGraph.kt
index 0f3444d66..d8195c5d8 100644
--- a/app/src/main/java/team/aliens/dms/android/app/navigation/authorized/AuthorizedNavGraph.kt
+++ b/app/src/main/java/team/aliens/dms/android/app/navigation/authorized/AuthorizedNavGraph.kt
@@ -9,6 +9,7 @@ import team.aliens.dms.android.feature.destinations.EditProfileImageScreenDestin
import team.aliens.dms.android.feature.destinations.MainDestination
import team.aliens.dms.android.feature.destinations.NoticeDetailsScreenDestination
import team.aliens.dms.android.feature.destinations.NotificationBoxScreenDestination
+import team.aliens.dms.android.feature.destinations.NotificationSettingsScreenDestination
import team.aliens.dms.android.feature.destinations.PointHistoryScreenDestination
import team.aliens.dms.android.feature.destinations.RemainsApplicationScreenDestination
import team.aliens.dms.android.feature.destinations.StudyRoomDetailsScreenDestination
@@ -37,6 +38,7 @@ object AuthorizedNavGraph : NavGraphSpec {
StudyRoomDetailsScreenDestination,
NoticeDetailsScreenDestination,
NotificationBoxScreenDestination,
+ NotificationSettingsScreenDestination,
PointHistoryScreenDestination,
)
.routedIn(navGraphSpec = this)
diff --git a/app/src/main/java/team/aliens/dms/android/app/navigation/authorized/AuthorizedNavigator.kt b/app/src/main/java/team/aliens/dms/android/app/navigation/authorized/AuthorizedNavigator.kt
index 7e113dc17..ce57953a8 100644
--- a/app/src/main/java/team/aliens/dms/android/app/navigation/authorized/AuthorizedNavigator.kt
+++ b/app/src/main/java/team/aliens/dms/android/app/navigation/authorized/AuthorizedNavigator.kt
@@ -4,7 +4,8 @@ import team.aliens.dms.android.feature.editpassword.navigation.EditPasswordNavig
import team.aliens.dms.android.feature.editprofile.navigation.EditProfileNavigator
import team.aliens.dms.android.feature.main.navigation.MainNavigator
import team.aliens.dms.android.feature.notice.navigation.NoticeNavigator
-import team.aliens.dms.android.feature.notification.navigation.NotificationNavigation
+import team.aliens.dms.android.feature.notification.navigation.NotificationBoxNavigator
+import team.aliens.dms.android.feature.notification.navigation.NotificationSettingsNavigator
import team.aliens.dms.android.feature.outing.navigation.OutingNavigator
import team.aliens.dms.android.feature.point.navigation.PointHistoryNavigator
import team.aliens.dms.android.feature.remains.navigator.RemainsNavigator
@@ -15,7 +16,8 @@ interface AuthorizedNavigator :
EditPasswordNavigator,
EditProfileNavigator,
NoticeNavigator,
- NotificationNavigation,
+ NotificationBoxNavigator,
+ NotificationSettingsNavigator,
PointHistoryNavigator,
RemainsNavigator,
StudyRoomNavigator,
diff --git a/app/src/main/java/team/aliens/dms/android/app/service/DmsMessagingService.kt b/app/src/main/java/team/aliens/dms/android/app/service/DmsMessagingService.kt
new file mode 100644
index 000000000..c725efbdd
--- /dev/null
+++ b/app/src/main/java/team/aliens/dms/android/app/service/DmsMessagingService.kt
@@ -0,0 +1,40 @@
+package team.aliens.dms.android.app.service
+
+import com.google.firebase.messaging.FirebaseMessagingService
+import com.google.firebase.messaging.RemoteMessage
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import team.aliens.dms.android.core.notification.DeviceTokenManager
+import team.aliens.dms.android.app.NotificationManager
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class DmsMessagingService : FirebaseMessagingService() {
+
+ @Inject
+ lateinit var deviceTokenManager: DeviceTokenManager
+
+ private val notificationManager: NotificationManager by lazy {
+ NotificationManager(context = this)
+ }
+
+ override fun onNewToken(deviceToken: String) {
+ super.onNewToken(deviceToken)
+ CoroutineScope(Dispatchers.IO).launch {
+ deviceTokenManager.saveDeviceToken(deviceToken = deviceToken)
+ }
+ }
+
+ override fun onMessageReceived(message: RemoteMessage) {
+ super.onMessageReceived(message)
+ message.notification?.run {
+ notificationManager.setNotificationContent(
+ title = title,
+ body = body,
+ )
+ }
+ notificationManager.sendNotification()
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c37fe347f..d7a14af03 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,4 +1,5 @@
DMS
+ team.aliens.dms.android
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index 8d0403270..68866b9bf 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,6 +1,17 @@
// TODO: Remove once KTIJ-19369 is fixed
@file:Suppress("DSL_SCOPE_VIOLATION")
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+
+ dependencies {
+ classpath(libs.google.services)
+ }
+}
+
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
@@ -9,4 +20,5 @@ plugins {
alias(libs.plugins.googleServices) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.jetbrainsKotlinJvm) apply false
+ alias(libs.plugins.firebase.crashlytics) apply false
}
diff --git a/buildSrc/src/main/kotlin/ProjectPaths.kt b/buildSrc/src/main/kotlin/ProjectPaths.kt
index bbf50a9c2..28e2da484 100644
--- a/buildSrc/src/main/kotlin/ProjectPaths.kt
+++ b/buildSrc/src/main/kotlin/ProjectPaths.kt
@@ -15,6 +15,9 @@ object ProjectPaths {
const val SCHOOL = ":core:school"
const val UI = ":core:ui"
const val FILE = ":core:file"
+ const val NOTIFICATION = ":core:notification"
+ const val DEVICE = ":core:device"
+ const val WIDGET = ":core:widget"
}
object Shared {
diff --git a/core/design-system/src/main/java/team/aliens/dms/android/core/designsystem/Buttons.kt b/core/design-system/src/main/java/team/aliens/dms/android/core/designsystem/Buttons.kt
index 08ab60a4c..20f5b6ec2 100644
--- a/core/design-system/src/main/java/team/aliens/dms/android/core/designsystem/Buttons.kt
+++ b/core/design-system/src/main/java/team/aliens/dms/android/core/designsystem/Buttons.kt
@@ -39,7 +39,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
-fun Button(
+private fun Button(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
@@ -63,7 +63,7 @@ fun Button(
contentColor = contentColor,
shadowElevation = shadowElevation,
border = border,
- interactionSource = interactionSource
+ interactionSource = interactionSource,
) {
CompositionLocalProvider(LocalContentColor provides contentColor) {
ProvideTextStyle(value = DmsTheme.typography.button) {
diff --git a/core/design-system/src/main/java/team/aliens/dms/android/core/designsystem/DmsIcon.kt b/core/design-system/src/main/java/team/aliens/dms/android/core/designsystem/DmsIcon.kt
index a4deca8a5..0d2cacdf3 100644
--- a/core/design-system/src/main/java/team/aliens/dms/android/core/designsystem/DmsIcon.kt
+++ b/core/design-system/src/main/java/team/aliens/dms/android/core/designsystem/DmsIcon.kt
@@ -5,6 +5,7 @@ object DmsIcon {
val Back = R.drawable.ic_back
val Backward = R.drawable.ic_backward
val Bell = R.drawable.ic_bell
+ val BlueBell = R.drawable.ic_blue_bell
val BlueBreakfast = R.drawable.ic_blue_breakfast
val BlueDinner = R.drawable.ic_blue_dinner
val BlueLaunch = R.drawable.ic_blue_lunch
@@ -21,8 +22,10 @@ object DmsIcon {
val Information = R.drawable.ic_information
val LogoDark = R.drawable.ic_logo_dark
val LogoLight = R.drawable.ic_logo_light
+ val Logo = R.drawable.ic_logo
+ val Notification = R.drawable.ic_notification
val Lunch = R.drawable.ic_lunch
- val MyPage = R.drawable.ic_person
+ val Person = R.drawable.ic_person
val Notice = R.drawable.ic_notice
val Palette = R.drawable.ic_palette
val PasswordInvisible = R.drawable.ic_password_invisible
diff --git a/core/design-system/src/main/res/drawable-hdpi/ic_notification.png b/core/design-system/src/main/res/drawable-hdpi/ic_notification.png
new file mode 100644
index 000000000..4c0063094
Binary files /dev/null and b/core/design-system/src/main/res/drawable-hdpi/ic_notification.png differ
diff --git a/core/design-system/src/main/res/drawable-mdpi/ic_notification.png b/core/design-system/src/main/res/drawable-mdpi/ic_notification.png
new file mode 100644
index 000000000..364ae0940
Binary files /dev/null and b/core/design-system/src/main/res/drawable-mdpi/ic_notification.png differ
diff --git a/core/design-system/src/main/res/drawable-xhdpi/ic_notification.png b/core/design-system/src/main/res/drawable-xhdpi/ic_notification.png
new file mode 100644
index 000000000..f03f9acca
Binary files /dev/null and b/core/design-system/src/main/res/drawable-xhdpi/ic_notification.png differ
diff --git a/core/design-system/src/main/res/drawable-xxhdpi/ic_notification.png b/core/design-system/src/main/res/drawable-xxhdpi/ic_notification.png
new file mode 100644
index 000000000..8d3e1011d
Binary files /dev/null and b/core/design-system/src/main/res/drawable-xxhdpi/ic_notification.png differ
diff --git a/core/design-system/src/main/res/drawable-xxxhdpi/ic_notification.png b/core/design-system/src/main/res/drawable-xxxhdpi/ic_notification.png
new file mode 100644
index 000000000..d421428fb
Binary files /dev/null and b/core/design-system/src/main/res/drawable-xxxhdpi/ic_notification.png differ
diff --git a/core/design-system/src/main/res/drawable/ic_blue_bell.xml b/core/design-system/src/main/res/drawable/ic_blue_bell.xml
new file mode 100644
index 000000000..13d87c4e3
--- /dev/null
+++ b/core/design-system/src/main/res/drawable/ic_blue_bell.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/core/design-system/src/main/res/drawable/ic_logo.png b/core/design-system/src/main/res/drawable/ic_logo.png
new file mode 100644
index 000000000..290022b56
Binary files /dev/null and b/core/design-system/src/main/res/drawable/ic_logo.png differ
diff --git a/core/design-system/src/main/res/drawable/ic_person.xml b/core/design-system/src/main/res/drawable/ic_person.xml
index edca5fdb9..32b3100a3 100644
--- a/core/design-system/src/main/res/drawable/ic_person.xml
+++ b/core/design-system/src/main/res/drawable/ic_person.xml
@@ -1,5 +1,9 @@
-
-
-
-
+
+
diff --git a/core/device/.gitignore b/core/device/.gitignore
new file mode 100644
index 000000000..42afabfd2
--- /dev/null
+++ b/core/device/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/device/build.gradle.kts b/core/device/build.gradle.kts
new file mode 100644
index 000000000..e687bb982
--- /dev/null
+++ b/core/device/build.gradle.kts
@@ -0,0 +1,53 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.hilt)
+ alias(libs.plugins.ksp)
+}
+
+android {
+ namespace = "team.aliens.dms.android.core.device"
+ compileSdk = libs.versions.compileSdk.get().toInt()
+
+ defaultConfig {
+ minSdk = libs.versions.minSdk.get().toInt()
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = Versions.java
+ targetCompatibility = Versions.java
+ }
+
+ kotlinOptions {
+ jvmTarget = Versions.java.toString()
+ }
+}
+
+dependencies {
+ implementation(project(ProjectPaths.Core.DATASTORE))
+
+ implementation(libs.androidx.core)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.material)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso)
+
+ implementation(libs.androidx.datastore.preferences)
+
+ implementation(libs.hilt)
+ ksp(libs.hilt.compiler)
+}
\ No newline at end of file
diff --git a/core/device/consumer-rules.pro b/core/device/consumer-rules.pro
new file mode 100644
index 000000000..e69de29bb
diff --git a/core/device/proguard-rules.pro b/core/device/proguard-rules.pro
new file mode 100644
index 000000000..481bb4348
--- /dev/null
+++ b/core/device/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/core/device/src/androidTest/java/team/aliens/dms/android/core/device/ExampleInstrumentedTest.kt b/core/device/src/androidTest/java/team/aliens/dms/android/core/device/ExampleInstrumentedTest.kt
new file mode 100644
index 000000000..b5aab263c
--- /dev/null
+++ b/core/device/src/androidTest/java/team/aliens/dms/android/core/device/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package team.aliens.dms.android.core.device
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("team.aliens.dms.android.core.device.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/core/device/src/main/AndroidManifest.xml b/core/device/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..a5918e68a
--- /dev/null
+++ b/core/device/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/core/device/src/main/java/team/aliens/dms/android/core/device/datastore/DeviceDataStoreDataSource.kt b/core/device/src/main/java/team/aliens/dms/android/core/device/datastore/DeviceDataStoreDataSource.kt
new file mode 100644
index 000000000..6db1676d7
--- /dev/null
+++ b/core/device/src/main/java/team/aliens/dms/android/core/device/datastore/DeviceDataStoreDataSource.kt
@@ -0,0 +1,11 @@
+package team.aliens.dms.android.core.device.datastore
+
+abstract class DeviceDataStoreDataSource {
+
+ abstract fun loadDeviceToken(): String
+
+ abstract suspend fun storeDeviceToken(deviceToken: String)
+
+ abstract suspend fun clearDeviceToken()
+
+}
diff --git a/core/device/src/main/java/team/aliens/dms/android/core/device/datastore/DeviceDataStoreDataSourceImpl.kt b/core/device/src/main/java/team/aliens/dms/android/core/device/datastore/DeviceDataStoreDataSourceImpl.kt
new file mode 100644
index 000000000..9d403883a
--- /dev/null
+++ b/core/device/src/main/java/team/aliens/dms/android/core/device/datastore/DeviceDataStoreDataSourceImpl.kt
@@ -0,0 +1,18 @@
+package team.aliens.dms.android.core.device.datastore
+
+import team.aliens.dms.android.core.device.datastore.store.DeviceStore
+import javax.inject.Inject
+
+internal class DeviceDataStoreDataSourceImpl @Inject constructor(
+ private val deviceStore: DeviceStore,
+) : DeviceDataStoreDataSource() {
+ override fun loadDeviceToken(): String = deviceStore.loadDeviceToken()
+
+ override suspend fun storeDeviceToken(deviceToken: String) {
+ deviceStore.storeDeviceToken(deviceToken)
+ }
+
+ override suspend fun clearDeviceToken() {
+ deviceStore.clearDeviceToken()
+ }
+}
diff --git a/core/device/src/main/java/team/aliens/dms/android/core/device/datastore/store/DeviceStore.kt b/core/device/src/main/java/team/aliens/dms/android/core/device/datastore/store/DeviceStore.kt
new file mode 100644
index 000000000..adeca6b36
--- /dev/null
+++ b/core/device/src/main/java/team/aliens/dms/android/core/device/datastore/store/DeviceStore.kt
@@ -0,0 +1,10 @@
+package team.aliens.dms.android.core.device.datastore.store
+
+internal abstract class DeviceStore {
+
+ abstract fun loadDeviceToken(): String
+
+ abstract suspend fun storeDeviceToken(deviceToken: String)
+
+ abstract suspend fun clearDeviceToken()
+}
diff --git a/core/device/src/main/java/team/aliens/dms/android/core/device/datastore/store/DeviceStoreImpl.kt b/core/device/src/main/java/team/aliens/dms/android/core/device/datastore/store/DeviceStoreImpl.kt
new file mode 100644
index 000000000..3ab21c74c
--- /dev/null
+++ b/core/device/src/main/java/team/aliens/dms/android/core/device/datastore/store/DeviceStoreImpl.kt
@@ -0,0 +1,42 @@
+package team.aliens.dms.android.core.device.datastore.store
+
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.runBlocking
+import team.aliens.dms.android.core.datastore.PreferencesDataStore
+import team.aliens.dms.android.core.datastore.util.transform
+import team.aliens.dms.android.core.device.datastore.store.exception.CannotStoreDeviceTokenException
+import team.aliens.dms.android.core.device.datastore.store.exception.DeviceTokenNotFoundException
+import javax.inject.Inject
+
+internal class DeviceStoreImpl @Inject constructor(
+ private val preferencesDataStore: PreferencesDataStore,
+) : DeviceStore() {
+ override fun loadDeviceToken(): String = runBlocking {
+ preferencesDataStore.data.map { preferences ->
+ preferences[DEVICE_TOKEN] ?: throw DeviceTokenNotFoundException()
+ }.first()
+ }
+
+ override suspend fun storeDeviceToken(deviceToken: String) {
+ transform(
+ onFailure = { throw CannotStoreDeviceTokenException() },
+ ) {
+ preferencesDataStore.edit { preferences ->
+ preferences[DEVICE_TOKEN] = deviceToken
+ }
+ }
+ }
+
+ override suspend fun clearDeviceToken() {
+ transform {
+ preferencesDataStore.edit { preferences -> preferences.clear() }
+ }
+ }
+
+ private companion object {
+ val DEVICE_TOKEN = stringPreferencesKey("device-token")
+ }
+}
diff --git a/core/device/src/main/java/team/aliens/dms/android/core/device/datastore/store/exception/CannotStoreDeviceTokenException.kt b/core/device/src/main/java/team/aliens/dms/android/core/device/datastore/store/exception/CannotStoreDeviceTokenException.kt
new file mode 100644
index 000000000..0a51c63e9
--- /dev/null
+++ b/core/device/src/main/java/team/aliens/dms/android/core/device/datastore/store/exception/CannotStoreDeviceTokenException.kt
@@ -0,0 +1,5 @@
+package team.aliens.dms.android.core.device.datastore.store.exception
+
+import team.aliens.dms.android.core.datastore.exception.TransformFailureException
+
+class CannotStoreDeviceTokenException : TransformFailureException("Cannot store deviceToken")
diff --git a/core/device/src/main/java/team/aliens/dms/android/core/device/datastore/store/exception/TokenNotFoundException.kt b/core/device/src/main/java/team/aliens/dms/android/core/device/datastore/store/exception/TokenNotFoundException.kt
new file mode 100644
index 000000000..10583752b
--- /dev/null
+++ b/core/device/src/main/java/team/aliens/dms/android/core/device/datastore/store/exception/TokenNotFoundException.kt
@@ -0,0 +1,7 @@
+package team.aliens.dms.android.core.device.datastore.store.exception
+
+import team.aliens.dms.android.core.datastore.exception.LoadFailureException
+
+sealed class TokenNotFoundException(message: String?) : LoadFailureException(message)
+
+class DeviceTokenNotFoundException : TokenNotFoundException("Device token not found")
diff --git a/core/device/src/main/java/team/aliens/dms/android/core/device/di/DataSourceModule.kt b/core/device/src/main/java/team/aliens/dms/android/core/device/di/DataSourceModule.kt
new file mode 100644
index 000000000..3ecf4ae4d
--- /dev/null
+++ b/core/device/src/main/java/team/aliens/dms/android/core/device/di/DataSourceModule.kt
@@ -0,0 +1,18 @@
+package team.aliens.dms.android.core.device.di
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import team.aliens.dms.android.core.device.datastore.DeviceDataStoreDataSource
+import team.aliens.dms.android.core.device.datastore.DeviceDataStoreDataSourceImpl
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal abstract class DataSourceModule {
+
+ @Binds
+ @Singleton
+ abstract fun bindDeviceDataStoreDataSource(impl: DeviceDataStoreDataSourceImpl): DeviceDataStoreDataSource
+}
diff --git a/core/device/src/main/java/team/aliens/dms/android/core/device/di/StoreModule.kt b/core/device/src/main/java/team/aliens/dms/android/core/device/di/StoreModule.kt
new file mode 100644
index 000000000..f5311733c
--- /dev/null
+++ b/core/device/src/main/java/team/aliens/dms/android/core/device/di/StoreModule.kt
@@ -0,0 +1,18 @@
+package team.aliens.dms.android.core.device.di
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import team.aliens.dms.android.core.device.datastore.store.DeviceStore
+import team.aliens.dms.android.core.device.datastore.store.DeviceStoreImpl
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal abstract class StoreModule {
+
+ @Binds
+ @Singleton
+ abstract fun bindDeviceStore(impl: DeviceStoreImpl): DeviceStore
+}
diff --git a/core/device/src/test/java/team/aliens/dms/android/core/device/ExampleUnitTest.kt b/core/device/src/test/java/team/aliens/dms/android/core/device/ExampleUnitTest.kt
new file mode 100644
index 000000000..2e898951e
--- /dev/null
+++ b/core/device/src/test/java/team/aliens/dms/android/core/device/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package team.aliens.dms.android.core.device
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/core/notification/.gitignore b/core/notification/.gitignore
new file mode 100644
index 000000000..42afabfd2
--- /dev/null
+++ b/core/notification/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/notification/build.gradle.kts b/core/notification/build.gradle.kts
new file mode 100644
index 000000000..ce20a1f6b
--- /dev/null
+++ b/core/notification/build.gradle.kts
@@ -0,0 +1,60 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.hilt)
+ alias(libs.plugins.ksp)
+}
+
+android {
+ namespace = "team.aliens.dms.android.core.notification"
+ compileSdk = libs.versions.compileSdk.get().toInt()
+
+ defaultConfig {
+ minSdk = libs.versions.minSdk.get().toInt()
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ buildFeatures {
+ buildConfig = true
+ }
+
+ compileOptions {
+ sourceCompatibility = Versions.java
+ targetCompatibility = Versions.java
+ }
+
+ kotlinOptions {
+ jvmTarget = Versions.java.toString()
+ }
+}
+
+dependencies {
+ implementation(project(ProjectPaths.DATA))
+ implementation(project(ProjectPaths.Core.DESIGN_SYSTEM))
+
+ implementation(libs.androidx.core)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.material)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso)
+
+ implementation(libs.hilt)
+ ksp(libs.hilt.compiler)
+
+ implementation(platform(libs.firebase.bom))
+ implementation(libs.firebase.messaging)
+ implementation(libs.firebase.analytics)
+}
diff --git a/core/notification/consumer-rules.pro b/core/notification/consumer-rules.pro
new file mode 100644
index 000000000..e69de29bb
diff --git a/core/notification/proguard-rules.pro b/core/notification/proguard-rules.pro
new file mode 100644
index 000000000..481bb4348
--- /dev/null
+++ b/core/notification/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/core/notification/src/androidTest/java/team/aliens/dms/android/core/notification/ExampleInstrumentedTest.kt b/core/notification/src/androidTest/java/team/aliens/dms/android/core/notification/ExampleInstrumentedTest.kt
new file mode 100644
index 000000000..43060cfcc
--- /dev/null
+++ b/core/notification/src/androidTest/java/team/aliens/dms/android/core/notification/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package team.aliens.dms.android.core.notification
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("team.aliens.dms.android.core.notification.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/core/notification/src/main/AndroidManifest.xml b/core/notification/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..1818d8cdc
--- /dev/null
+++ b/core/notification/src/main/AndroidManifest.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/core/notification/src/main/java/team/aliens/dms/android/core/notification/CheckNotificationPermission.kt b/core/notification/src/main/java/team/aliens/dms/android/core/notification/CheckNotificationPermission.kt
new file mode 100644
index 000000000..8d80eefa0
--- /dev/null
+++ b/core/notification/src/main/java/team/aliens/dms/android/core/notification/CheckNotificationPermission.kt
@@ -0,0 +1,15 @@
+package team.aliens.dms.android.core.notification
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.core.app.ActivityCompat
+
+fun notificationPermissionGranted(context: Context): Boolean {
+ return (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ ActivityCompat.checkSelfPermission(
+ context,
+ Manifest.permission.POST_NOTIFICATIONS,
+ ) == PackageManager.PERMISSION_GRANTED) || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
+}
diff --git a/core/notification/src/main/java/team/aliens/dms/android/core/notification/DeviceTokenManager.kt b/core/notification/src/main/java/team/aliens/dms/android/core/notification/DeviceTokenManager.kt
new file mode 100644
index 000000000..c5b160301
--- /dev/null
+++ b/core/notification/src/main/java/team/aliens/dms/android/core/notification/DeviceTokenManager.kt
@@ -0,0 +1,27 @@
+package team.aliens.dms.android.core.notification
+
+import com.google.firebase.messaging.FirebaseMessaging
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import team.aliens.dms.android.data.notification.repository.NotificationRepository
+import javax.inject.Inject
+
+class DeviceTokenManager @Inject constructor(
+ private val notificationRepository: NotificationRepository,
+) {
+ suspend fun saveDeviceToken(deviceToken: String) {
+ notificationRepository.saveDeviceToken(deviceToken = deviceToken)
+ }
+
+ suspend fun fetchDeviceToken() {
+ FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
+ if (!task.isSuccessful) {
+ // TODO: Handle error
+ }
+ CoroutineScope(Dispatchers.IO).launch {
+ notificationRepository.saveDeviceToken(deviceToken = task.result)
+ }
+ }
+ }
+}
diff --git a/core/notification/src/test/java/team/aliens/dms/android/core/notification/ExampleUnitTest.kt b/core/notification/src/test/java/team/aliens/dms/android/core/notification/ExampleUnitTest.kt
new file mode 100644
index 000000000..826dfaeae
--- /dev/null
+++ b/core/notification/src/test/java/team/aliens/dms/android/core/notification/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package team.aliens.dms.android.core.notification
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/core/widget/.gitignore b/core/widget/.gitignore
new file mode 100644
index 000000000..42afabfd2
--- /dev/null
+++ b/core/widget/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/widget/build.gradle.kts b/core/widget/build.gradle.kts
new file mode 100644
index 000000000..d00ab220b
--- /dev/null
+++ b/core/widget/build.gradle.kts
@@ -0,0 +1,82 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.hilt)
+ alias(libs.plugins.serialization)
+}
+
+android {
+ namespace = "team.aliens.dms.android.core.widget"
+ compileSdk = libs.versions.compileSdk.get().toInt()
+
+ defaultConfig {
+ minSdk = libs.versions.minSdk.get().toInt()
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ buildFeatures {
+ compose = true
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.compose.get()
+ }
+
+ compileOptions {
+ sourceCompatibility = Versions.java
+ targetCompatibility = Versions.java
+ }
+
+ kotlinOptions {
+ jvmTarget = Versions.java.toString()
+ }
+}
+
+dependencies {
+
+ implementation(project(ProjectPaths.Core.DESIGN_SYSTEM))
+
+ implementation(project(ProjectPaths.DATA))
+
+ implementation(project(ProjectPaths.Shared.DATE))
+
+ implementation(libs.androidx.core)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.material)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso)
+
+ implementation(libs.androidx.compose)
+ implementation(libs.androidx.compose.material)
+
+ implementation(libs.threetenbp)
+
+ implementation(libs.hilt)
+ testImplementation(libs.hilt.testing)
+ ksp(libs.hilt.compiler)
+ kspTest(libs.hilt.compiler)
+
+ implementation(libs.androidx.glance.appwidget)
+
+ implementation(libs.androidx.work.runtime.ktx)
+
+ implementation(libs.androidx.hilt.work)
+ ksp(libs.androidx.hilt.compiler)
+
+ implementation (libs.kotlin.stdlib)
+ implementation(libs.kotlinx.serialization.json)
+}
diff --git a/core/widget/consumer-rules.pro b/core/widget/consumer-rules.pro
new file mode 100644
index 000000000..e69de29bb
diff --git a/core/widget/proguard-rules.pro b/core/widget/proguard-rules.pro
new file mode 100644
index 000000000..481bb4348
--- /dev/null
+++ b/core/widget/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/core/widget/src/androidTest/java/team/aliens/dms/android/core/widget/ExampleInstrumentedTest.kt b/core/widget/src/androidTest/java/team/aliens/dms/android/core/widget/ExampleInstrumentedTest.kt
new file mode 100644
index 000000000..eadac4029
--- /dev/null
+++ b/core/widget/src/androidTest/java/team/aliens/dms/android/core/widget/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package team.aliens.dms.android.core.widget
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("team.aliens.dms.android.core.widget.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/core/widget/src/main/AndroidManifest.xml b/core/widget/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..a5918e68a
--- /dev/null
+++ b/core/widget/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/core/widget/src/main/java/team/aliens/dms/android/core/widget/designsystem/Colors.kt b/core/widget/src/main/java/team/aliens/dms/android/core/widget/designsystem/Colors.kt
new file mode 100644
index 000000000..79dd26b77
--- /dev/null
+++ b/core/widget/src/main/java/team/aliens/dms/android/core/widget/designsystem/Colors.kt
@@ -0,0 +1,64 @@
+package team.aliens.dms.android.core.widget.designsystem
+
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.glance.unit.ColorProvider
+import team.aliens.dms.android.core.designsystem.ErrorDarken2
+import team.aliens.dms.android.core.designsystem.ErrorDefault
+import team.aliens.dms.android.core.designsystem.ErrorLighten2
+import team.aliens.dms.android.core.designsystem.Gray1
+import team.aliens.dms.android.core.designsystem.Gray10
+import team.aliens.dms.android.core.designsystem.Gray2
+import team.aliens.dms.android.core.designsystem.Gray3
+import team.aliens.dms.android.core.designsystem.Gray5
+import team.aliens.dms.android.core.designsystem.Gray6
+import team.aliens.dms.android.core.designsystem.Gray7
+import team.aliens.dms.android.core.designsystem.Gray9
+import team.aliens.dms.android.core.designsystem.PrimaryDarken2
+import team.aliens.dms.android.core.designsystem.PrimaryDefault
+import team.aliens.dms.android.core.designsystem.PrimaryLighten2
+
+data class ColorProviders(
+ val primary: ColorProvider,
+ val onPrimary: ColorProvider,
+ val primaryContainer: ColorProvider,
+ val onPrimaryContainer: ColorProvider,
+ val error: ColorProvider,
+ val onError: ColorProvider,
+ val errorContainer: ColorProvider,
+ val onErrorContainer: ColorProvider,
+ val background: ColorProvider,
+ val onBackground: ColorProvider,
+ val backgroundVariant: ColorProvider,
+ val onBackgroundVariant: ColorProvider,
+ val surface: ColorProvider,
+ val onSurface: ColorProvider,
+ val surfaceVariant: ColorProvider,
+ val onSurfaceVariant: ColorProvider,
+ val icon: ColorProvider,
+ val line: ColorProvider,
+)
+
+fun dynamicThemeColorProviders(): ColorProviders {
+ return ColorProviders(
+ primary = ColorProvider(PrimaryDefault),
+ onPrimary = ColorProvider(Gray1),
+ primaryContainer = ColorProvider(PrimaryLighten2),
+ onPrimaryContainer = ColorProvider(PrimaryDarken2),
+ error = ColorProvider(ErrorDefault),
+ onError = ColorProvider(Gray1),
+ errorContainer = ColorProvider(ErrorLighten2),
+ onErrorContainer = ColorProvider(ErrorDarken2),
+ background = ColorProvider(Gray2),
+ onBackground = ColorProvider(Gray10),
+ backgroundVariant = ColorProvider(Gray7),
+ onBackgroundVariant = ColorProvider(Gray3),
+ surface = ColorProvider(Gray1),
+ onSurface = ColorProvider(Gray9),
+ surfaceVariant = ColorProvider(Gray6),
+ onSurfaceVariant = ColorProvider(Gray5),
+ icon = ColorProvider(Gray5),
+ line = ColorProvider(Gray3),
+ )
+}
+
+internal val LocalColorProviders = staticCompositionLocalOf { dynamicThemeColorProviders() }
diff --git a/core/widget/src/main/java/team/aliens/dms/android/core/widget/designsystem/WidgetTheme.kt b/core/widget/src/main/java/team/aliens/dms/android/core/widget/designsystem/WidgetTheme.kt
new file mode 100644
index 000000000..88c70d9ab
--- /dev/null
+++ b/core/widget/src/main/java/team/aliens/dms/android/core/widget/designsystem/WidgetTheme.kt
@@ -0,0 +1,25 @@
+package team.aliens.dms.android.core.widget.designsystem
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.ReadOnlyComposable
+
+object WidgetTheme {
+ val colors: ColorProviders
+ @Composable
+ @ReadOnlyComposable
+ get() = LocalColorProviders.current
+
+}
+
+
+@Composable
+fun WidgetTheme(
+ colors: ColorProviders = WidgetTheme.colors,
+ content: @Composable () -> Unit,
+) {
+ CompositionLocalProvider(LocalColorProviders provides colors) {
+ content()
+ }
+}
+
diff --git a/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/MealGlanceWidget.kt b/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/MealGlanceWidget.kt
new file mode 100644
index 000000000..ba30cbfb8
--- /dev/null
+++ b/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/MealGlanceWidget.kt
@@ -0,0 +1,194 @@
+package team.aliens.dms.android.core.widget.meal
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.Image
+import androidx.glance.ImageProvider
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.lazy.LazyColumn
+import androidx.glance.appwidget.lazy.items
+import androidx.glance.appwidget.provideContent
+import androidx.glance.background
+import androidx.glance.currentState
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Box
+import androidx.glance.layout.Column
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxHeight
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.padding
+import androidx.glance.layout.width
+import androidx.glance.layout.wrapContentWidth
+import androidx.glance.state.GlanceStateDefinition
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextStyle
+import team.aliens.dms.android.core.designsystem.DmsTheme
+import team.aliens.dms.android.core.widget.designsystem.WidgetTheme
+
+class MealGlanceWidget : GlanceAppWidget() {
+
+ override val stateDefinition: GlanceStateDefinition<*>
+ get() = MealInfoStateDefinition
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ provideContent {
+ MealWidget()
+ }
+ }
+
+ @Composable
+ internal fun MealWidget() {
+ val mealInfo = currentState()
+
+ GlanceTheme {
+ when (mealInfo) {
+ MealInfo.Loading -> Loading()
+
+ is MealInfo.Unavailable -> Unavailable()
+
+ is MealInfo.Available -> {
+ when (MealType.getCurrentMealType()) {
+ MealType.Breakfast -> {
+ MealBig(
+ mealState = MealState(
+ mealType = MealType.Breakfast,
+ meal = mealInfo.breakfast,
+ calories = mealInfo.kcalOfBreakfast,
+ )
+ )
+ }
+
+ MealType.Launch -> {
+ MealBig(
+ mealState = MealState(
+ mealType = MealType.Launch,
+ meal = mealInfo.lunch,
+ calories = mealInfo.kcalOfLunch,
+ )
+ )
+ }
+
+ MealType.Dinner -> {
+ MealBig(
+ mealState = MealState(
+ mealType = MealType.Dinner,
+ meal = mealInfo.dinner,
+ calories = mealInfo.kcalOfDinner,
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Composable
+ private fun MealBig(
+ mealState: MealState
+ ) {
+ Row(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .padding(
+ horizontal = 18.dp,
+ vertical = 16.dp,
+ )
+ .background(DmsTheme.colorScheme.background),
+ ) {
+ Column(
+ modifier = GlanceModifier.fillMaxHeight()
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Image(
+ provider = ImageProvider(mealState.mealType.icon),
+ contentDescription = null,
+ )
+ Spacer(GlanceModifier.width(4.dp))
+ Text(
+ text = mealState.mealType.title,
+ style = TextStyle(
+ color = WidgetTheme.colors.primary,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ )
+ }
+ Spacer(GlanceModifier.defaultWeight())
+ Text(
+ text = mealState.calories,
+ style = TextStyle(
+ color = WidgetTheme.colors.onSurfaceVariant,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Normal,
+ )
+ )
+ }
+ LazyColumn(
+ modifier = GlanceModifier
+ .fillMaxHeight()
+ .wrapContentWidth(),
+ horizontalAlignment = Alignment.End,
+ ) {
+ items(mealState.meal) {
+ Text(
+ text = it,
+ style = TextStyle(
+ color = WidgetTheme.colors.surfaceVariant,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Normal,
+ )
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun Loading() {
+ Box(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .background(DmsTheme.colorScheme.background),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "로딩중...",
+ style = TextStyle(
+ color = WidgetTheme.colors.primary,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ )
+ }
+
+}
+
+@Composable
+private fun Unavailable() {
+ Box(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .background(DmsTheme.colorScheme.background),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "급식을 불러오지 못했어요",
+ style = TextStyle(
+ color = WidgetTheme.colors.primary,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ )
+ }
+}
diff --git a/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/MealInfo.kt b/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/MealInfo.kt
new file mode 100644
index 000000000..48041a40c
--- /dev/null
+++ b/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/MealInfo.kt
@@ -0,0 +1,24 @@
+package team.aliens.dms.android.core.widget.meal
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+sealed interface MealInfo {
+
+ @Serializable
+ data object Loading : MealInfo
+
+ @Serializable
+ data class Available(
+ val date: String,
+ val breakfast: List,
+ val kcalOfBreakfast: String,
+ val lunch: List,
+ val kcalOfLunch: String,
+ val dinner: List,
+ val kcalOfDinner: String,
+ ) : MealInfo
+
+ @Serializable
+ data object Unavailable : MealInfo
+}
diff --git a/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/MealInfoStateDefinition.kt b/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/MealInfoStateDefinition.kt
new file mode 100644
index 000000000..a086aada2
--- /dev/null
+++ b/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/MealInfoStateDefinition.kt
@@ -0,0 +1,51 @@
+package team.aliens.dms.android.core.widget.meal
+
+import android.content.Context
+import androidx.datastore.core.CorruptionException
+import androidx.datastore.core.DataStore
+import androidx.datastore.core.Serializer
+import androidx.datastore.dataStore
+import androidx.datastore.dataStoreFile
+import androidx.glance.state.GlanceStateDefinition
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.json.Json
+import java.io.File
+import java.io.InputStream
+import java.io.OutputStream
+
+object MealInfoStateDefinition : GlanceStateDefinition {
+
+ private const val DATA_STORE_FILENAME = "mealInfo"
+
+ private val Context.datastore by dataStore(DATA_STORE_FILENAME, MealInfoSerializer)
+
+ override suspend fun getDataStore(context: Context, fileKey: String): DataStore {
+ return context.datastore
+ }
+
+ override fun getLocation(context: Context, fileKey: String): File {
+ return context.dataStoreFile(DATA_STORE_FILENAME)
+ }
+
+ object MealInfoSerializer : Serializer {
+ override val defaultValue: MealInfo
+ get() = MealInfo.Loading
+
+ override suspend fun readFrom(input: InputStream): MealInfo = try {
+ Json.decodeFromString(
+ MealInfo.serializer(),
+ input.readBytes().decodeToString()
+ )
+ } catch (exception: SerializationException) {
+ throw CorruptionException("Could not read meal data: ${exception.message}")
+ }
+
+ override suspend fun writeTo(t: MealInfo, output: OutputStream) {
+ output.use {
+ it.write(
+ Json.encodeToString(MealInfo.serializer(), t).encodeToByteArray()
+ )
+ }
+ }
+ }
+}
diff --git a/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/MealState.kt b/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/MealState.kt
new file mode 100644
index 000000000..2755db5f9
--- /dev/null
+++ b/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/MealState.kt
@@ -0,0 +1,7 @@
+package team.aliens.dms.android.core.widget.meal
+
+data class MealState(
+ val mealType: MealType = MealType.Breakfast,
+ val meal: List = emptyList(),
+ val calories: String = "",
+)
diff --git a/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/MealType.kt b/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/MealType.kt
new file mode 100644
index 000000000..62554e6bd
--- /dev/null
+++ b/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/MealType.kt
@@ -0,0 +1,35 @@
+package team.aliens.dms.android.core.widget.meal
+
+import team.aliens.dms.android.core.designsystem.DmsIcon
+import team.aliens.dms.android.shared.date.util.now
+
+private const val BreakfastStartTime: Int = 9
+private const val LunchStartTime: Int = 13
+private const val DinnerStartTime: Int = 19
+
+enum class MealType(
+ val icon: Int,
+ val title: String,
+) {
+ Breakfast(
+ icon = DmsIcon.BlueBreakfast,
+ title = "아침",
+ ),
+ Launch(
+ icon = DmsIcon.BlueLaunch,
+ title = "점심",
+ ),
+ Dinner(
+ icon = DmsIcon.BlueDinner,
+ title = "저녁",
+ ),
+ ;
+
+ companion object {
+ internal fun getCurrentMealType(): MealType = when (now.hour) {
+ in BreakfastStartTime until LunchStartTime -> Launch
+ in LunchStartTime until DinnerStartTime -> Dinner
+ else -> Breakfast
+ }
+ }
+}
diff --git a/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/MealWidgetReceiver.kt b/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/MealWidgetReceiver.kt
new file mode 100644
index 000000000..bbf99b5b7
--- /dev/null
+++ b/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/MealWidgetReceiver.kt
@@ -0,0 +1,20 @@
+package team.aliens.dms.android.core.widget.meal
+
+import android.content.Context
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+
+class MealWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget
+ get() = MealGlanceWidget()
+
+ override fun onEnabled(context: Context) {
+ super.onEnabled(context)
+ MealWorker.enqueue(context)
+ }
+
+ override fun onDisabled(context: Context) {
+ super.onDisabled(context)
+ MealWorker.cancel(context)
+ }
+}
diff --git a/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/MealWorker.kt b/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/MealWorker.kt
new file mode 100644
index 000000000..b208a6256
--- /dev/null
+++ b/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/MealWorker.kt
@@ -0,0 +1,95 @@
+package team.aliens.dms.android.core.widget.meal
+
+import android.content.Context
+import android.os.Build
+import androidx.glance.GlanceId
+import androidx.glance.appwidget.GlanceAppWidgetManager
+import androidx.glance.appwidget.state.updateAppWidgetState
+import androidx.glance.appwidget.updateAll
+import androidx.hilt.work.HiltWorker
+import androidx.work.Constraints
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import team.aliens.dms.android.core.widget.meal.mapper.toEntity
+import team.aliens.dms.android.data.meal.repository.MealRepository
+import team.aliens.dms.android.shared.date.util.now
+import team.aliens.dms.android.shared.date.util.today
+import java.time.Duration
+
+@HiltWorker
+class MealWorker @AssistedInject constructor(
+ @Assisted val context: Context,
+ @Assisted workerParameters: WorkerParameters,
+ private val mealRepository: MealRepository,
+) : CoroutineWorker(context, workerParameters) {
+
+ companion object {
+ private val uniqueWorkName = MealWorker::class.java.simpleName
+
+ internal fun enqueue(context: Context) {
+ val manager = WorkManager.getInstance(context)
+ val requestBuilder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ PeriodicWorkRequestBuilder(Duration.ofHours(1))
+ } else {
+ TODO("VERSION.SDK_INT < O")
+ }
+ val constrains = Constraints.Builder()
+ .setRequiresBatteryNotLow(true)
+ .build()
+
+ manager.enqueueUniquePeriodicWork(
+ uniqueWorkName,
+ ExistingPeriodicWorkPolicy.KEEP,
+ requestBuilder
+ .setConstraints(constrains)
+ .build(),
+ )
+ }
+
+ internal fun cancel(context: Context) {
+ WorkManager.getInstance(context).cancelUniqueWork(uniqueWorkName)
+ }
+ }
+
+ override suspend fun doWork(): Result {
+ val manager = GlanceAppWidgetManager(context)
+ val glanceIds = manager.getGlanceIds(MealGlanceWidget::class.java)
+
+ return try {
+ setWidgetState(glanceIds, MealInfo.Loading)
+
+ try {
+ val mealDate = if (now.hour >= 19) today.plusDays(1) else today
+ val response = mealRepository.fetchMeal(mealDate)
+ setWidgetState(glanceIds, response.toEntity())
+ } catch (e: Exception) {
+ Result.failure()
+ }
+ Result.success()
+ } catch (e: Exception) {
+ setWidgetState(glanceIds, MealInfo.Unavailable)
+ e.printStackTrace()
+ Result.failure()
+ }
+ }
+
+ private suspend fun setWidgetState(
+ glanceIds: List,
+ newState: MealInfo,
+ ) {
+ glanceIds.forEach { glanceId ->
+ updateAppWidgetState(
+ context = context,
+ glanceId = glanceId,
+ definition = MealInfoStateDefinition,
+ updateState = { newState },
+ )
+ }
+ MealGlanceWidget().updateAll(context)
+ }
+}
diff --git a/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/mapper/MealMapper.kt b/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/mapper/MealMapper.kt
new file mode 100644
index 000000000..7e5dfc030
--- /dev/null
+++ b/core/widget/src/main/java/team/aliens/dms/android/core/widget/meal/mapper/MealMapper.kt
@@ -0,0 +1,14 @@
+package team.aliens.dms.android.core.widget.meal.mapper
+
+import team.aliens.dms.android.core.widget.meal.MealInfo
+import team.aliens.dms.android.data.meal.model.Meal
+
+internal fun Meal.toEntity() = MealInfo.Available(
+ date = this.date.toString(),
+ breakfast = this.breakfast,
+ kcalOfBreakfast = this.kcalOfBreakfast!!,
+ lunch = this.lunch,
+ kcalOfLunch = this.kcalOfLunch!!,
+ dinner = this.dinner,
+ kcalOfDinner = this.kcalOfDinner!!,
+)
diff --git a/feature/src/main/res/drawable/shape_widget_small.xml b/core/widget/src/main/res/drawable/shape_widget_small.xml
similarity index 100%
rename from feature/src/main/res/drawable/shape_widget_small.xml
rename to core/widget/src/main/res/drawable/shape_widget_small.xml
diff --git a/feature/src/main/res/drawable/widget_big_meal.xml b/core/widget/src/main/res/drawable/widget_big_meal.xml
similarity index 100%
rename from feature/src/main/res/drawable/widget_big_meal.xml
rename to core/widget/src/main/res/drawable/widget_big_meal.xml
diff --git a/feature/src/main/res/drawable/widget_small_meal.xml b/core/widget/src/main/res/drawable/widget_small_meal.xml
similarity index 100%
rename from feature/src/main/res/drawable/widget_small_meal.xml
rename to core/widget/src/main/res/drawable/widget_small_meal.xml
diff --git a/feature/src/main/res/layout/big_widget_meal.xml b/core/widget/src/main/res/layout/big_widget_meal.xml
similarity index 93%
rename from feature/src/main/res/layout/big_widget_meal.xml
rename to core/widget/src/main/res/layout/big_widget_meal.xml
index 643cc7a2a..efba4fb68 100644
--- a/feature/src/main/res/layout/big_widget_meal.xml
+++ b/core/widget/src/main/res/layout/big_widget_meal.xml
@@ -22,7 +22,6 @@
+
+ 급식 메뉴
+ 오늘 급식을 빠르게 확인해보세요
+
\ No newline at end of file
diff --git a/feature/src/main/res/xml/big_meal_widget_provider.xml b/core/widget/src/main/res/xml/big_meal_widget_provider.xml
similarity index 75%
rename from feature/src/main/res/xml/big_meal_widget_provider.xml
rename to core/widget/src/main/res/xml/big_meal_widget_provider.xml
index 1d8bcc168..2ceb405bb 100644
--- a/feature/src/main/res/xml/big_meal_widget_provider.xml
+++ b/core/widget/src/main/res/xml/big_meal_widget_provider.xml
@@ -4,7 +4,8 @@
android:minWidth="240dp"
android:minHeight="50dp"
android:previewImage="@drawable/widget_big_meal"
+ android:description="@string/meal_widget_description"
android:resizeMode="horizontal|vertical"
- android:updatePeriodMillis="1800000"
+ android:updatePeriodMillis="3600000"
android:widgetCategory="home_screen">
-
\ No newline at end of file
+
diff --git a/feature/src/main/res/xml/small_meal_widget_provider.xml b/core/widget/src/main/res/xml/small_meal_widget_provider.xml
similarity index 100%
rename from feature/src/main/res/xml/small_meal_widget_provider.xml
rename to core/widget/src/main/res/xml/small_meal_widget_provider.xml
diff --git a/core/widget/src/test/java/team/aliens/dms/android/core/widget/ExampleUnitTest.kt b/core/widget/src/test/java/team/aliens/dms/android/core/widget/ExampleUnitTest.kt
new file mode 100644
index 000000000..17dd4c952
--- /dev/null
+++ b/core/widget/src/test/java/team/aliens/dms/android/core/widget/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package team.aliens.dms.android.core.widget
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/data/build.gradle.kts b/data/build.gradle.kts
index 956310c30..c8595d9e3 100644
--- a/data/build.gradle.kts
+++ b/data/build.gradle.kts
@@ -48,6 +48,7 @@ dependencies {
implementation(project(ProjectPaths.Core.JWT))
implementation(project(ProjectPaths.Core.NETWORK))
implementation(project(ProjectPaths.Core.SCHOOL))
+ implementation(project(ProjectPaths.Core.DEVICE))
implementation(project(ProjectPaths.DATABASE))
implementation(project(ProjectPaths.NETWORK))
diff --git a/data/src/main/java/team/aliens/dms/android/data/auth/repository/AuthRepository.kt b/data/src/main/java/team/aliens/dms/android/data/auth/repository/AuthRepository.kt
index c016d320b..d1ab33a8d 100644
--- a/data/src/main/java/team/aliens/dms/android/data/auth/repository/AuthRepository.kt
+++ b/data/src/main/java/team/aliens/dms/android/data/auth/repository/AuthRepository.kt
@@ -8,6 +8,7 @@ abstract class AuthRepository {
abstract suspend fun signIn(
accountId: String,
password: String,
+ deviceToken: String,
autoSignIn: Boolean = true,
)
diff --git a/data/src/main/java/team/aliens/dms/android/data/auth/repository/AuthRepositoryImpl.kt b/data/src/main/java/team/aliens/dms/android/data/auth/repository/AuthRepositoryImpl.kt
index f040e1e3e..9be53958e 100644
--- a/data/src/main/java/team/aliens/dms/android/data/auth/repository/AuthRepositoryImpl.kt
+++ b/data/src/main/java/team/aliens/dms/android/data/auth/repository/AuthRepositoryImpl.kt
@@ -24,6 +24,7 @@ internal class AuthRepositoryImpl @Inject constructor(
override suspend fun signIn(
accountId: String,
password: String,
+ deviceToken: String,
autoSignIn: Boolean,
) {
val signInResponse = statusMapping(
@@ -35,6 +36,7 @@ internal class AuthRepositoryImpl @Inject constructor(
request = SignInRequest(
accountId = accountId,
password = password,
+ deviceToken = deviceToken,
),
)
}
diff --git a/data/src/main/java/team/aliens/dms/android/data/notification/model/Notification.kt b/data/src/main/java/team/aliens/dms/android/data/notification/model/Notification.kt
index ed23b55b0..9d55a5f22 100644
--- a/data/src/main/java/team/aliens/dms/android/data/notification/model/Notification.kt
+++ b/data/src/main/java/team/aliens/dms/android/data/notification/model/Notification.kt
@@ -1,5 +1,8 @@
package team.aliens.dms.android.data.notification.model
+import org.threeten.bp.LocalDateTime
+import team.aliens.dms.android.network.notification.model.FetchNotificationsResponse
+import team.aliens.dms.android.shared.date.toLocalDateTime
import java.util.UUID
data class Notification(
@@ -8,6 +11,19 @@ data class Notification(
val linkId: UUID,
val title: String,
val content: String,
- val createdAt: String,
- val read: Boolean,
-)
\ No newline at end of file
+ val createdAt: LocalDateTime,
+ val isRead: Boolean,
+)
+
+fun FetchNotificationsResponse.toModel(): List =
+ this.notifications.map(FetchNotificationsResponse.NotificationResponse::toModel)
+
+private fun FetchNotificationsResponse.NotificationResponse.toModel(): Notification = Notification(
+ id = this.id,
+ topic = NotificationTopic.valueOf(this.topic),
+ linkId = this.linkId,
+ title = this.title,
+ content = this.content,
+ createdAt = this.createdAt.toLocalDateTime(),
+ isRead = this.isRead,
+)
diff --git a/data/src/main/java/team/aliens/dms/android/data/notification/model/NotificationTopic.kt b/data/src/main/java/team/aliens/dms/android/data/notification/model/NotificationTopic.kt
index 47b15383c..d7fe9a9ec 100644
--- a/data/src/main/java/team/aliens/dms/android/data/notification/model/NotificationTopic.kt
+++ b/data/src/main/java/team/aliens/dms/android/data/notification/model/NotificationTopic.kt
@@ -1,7 +1,9 @@
package team.aliens.dms.android.data.notification.model
+import team.aliens.dms.android.network.notification.model.BatchUpdateNotificationTopicRequest
+
enum class NotificationTopic {
- NOTICE,
+ NOTICE, STUDY_ROOM_TIME_SLOT, STUDY_ROOM_APPLY, POINT, OUTING,
;
data class Subscription(
@@ -9,3 +11,12 @@ enum class NotificationTopic {
val subscribe: Boolean,
)
}
+
+fun List.toModel(): List =
+ this.map(NotificationTopic.Subscription::toModel)
+
+private fun NotificationTopic.Subscription.toModel(): BatchUpdateNotificationTopicRequest.NotificationTopicRequest =
+ BatchUpdateNotificationTopicRequest.NotificationTopicRequest(
+ topic = this.topic.name,
+ subscribed = this.subscribe,
+ )
diff --git a/data/src/main/java/team/aliens/dms/android/data/notification/model/NotificationTopicGroup.kt b/data/src/main/java/team/aliens/dms/android/data/notification/model/NotificationTopicGroup.kt
index 604d0416b..02486a0cf 100644
--- a/data/src/main/java/team/aliens/dms/android/data/notification/model/NotificationTopicGroup.kt
+++ b/data/src/main/java/team/aliens/dms/android/data/notification/model/NotificationTopicGroup.kt
@@ -1,19 +1,42 @@
package team.aliens.dms.android.data.notification.model
+import team.aliens.dms.android.network.notification.model.FetchNotificationTopicStatusResponse
+
enum class NotificationTopicGroup {
- NOTICE, STUDY_ROOM, STUDY_ROOM_APPLY,
+ NOTICE, STUDY_ROOM, POINT, OUTING,
;
data class Status(
val topicGroup: NotificationTopicGroup,
- val groupTitle: String,
+ val groupName: String,
val topicSubscriptions: List,
) {
data class TopicSubscription(
val topic: NotificationTopic,
- val title: String,
- val description: String,
val subscribed: Boolean,
)
}
}
+
+fun FetchNotificationTopicStatusResponse.toModel(): List =
+ this.topicGroups.toModel()
+
+@JvmName("ListTopicGroupResponse")
+private fun List.toModel(): List =
+ this.map(FetchNotificationTopicStatusResponse.TopicGroupResponse::toModel)
+
+private fun FetchNotificationTopicStatusResponse.TopicGroupResponse.toModel(): NotificationTopicGroup.Status =
+ NotificationTopicGroup.Status(
+ topicGroup = NotificationTopicGroup.valueOf(this.topicGroup),
+ groupName = this.groupName,
+ topicSubscriptions = this.topicSubscriptions.toModel(),
+ )
+
+private fun List.toModel(): List =
+ this.map(FetchNotificationTopicStatusResponse.TopicGroupResponse.TopicSubscriptionResponse::toModel)
+
+private fun FetchNotificationTopicStatusResponse.TopicGroupResponse.TopicSubscriptionResponse.toModel(): NotificationTopicGroup.Status.TopicSubscription =
+ NotificationTopicGroup.Status.TopicSubscription(
+ topic = NotificationTopic.valueOf(this.topic),
+ subscribed = this.subscribed,
+ )
diff --git a/data/src/main/java/team/aliens/dms/android/data/notification/repository/NotificationRepository.kt b/data/src/main/java/team/aliens/dms/android/data/notification/repository/NotificationRepository.kt
index 2ee108b76..57e578eac 100644
--- a/data/src/main/java/team/aliens/dms/android/data/notification/repository/NotificationRepository.kt
+++ b/data/src/main/java/team/aliens/dms/android/data/notification/repository/NotificationRepository.kt
@@ -27,7 +27,11 @@ abstract class NotificationRepository {
abstract suspend fun batchUpdateNotificationTopic(subscriptions: List)
// TODO device token 파라미터 고민
- abstract suspend fun fetchNotificationStatus(deviceToken: String): NotificationTopicGroup.Status
+ abstract suspend fun fetchNotificationStatus(deviceToken: String): List
abstract suspend fun fetchNotifications(): List
+
+ abstract suspend fun saveDeviceToken(deviceToken: String)
+
+ abstract suspend fun getDeviceToken(): String
}
diff --git a/data/src/main/java/team/aliens/dms/android/data/notification/repository/NotificationRepositoryImpl.kt b/data/src/main/java/team/aliens/dms/android/data/notification/repository/NotificationRepositoryImpl.kt
index e919f759a..ada3b0bed 100644
--- a/data/src/main/java/team/aliens/dms/android/data/notification/repository/NotificationRepositoryImpl.kt
+++ b/data/src/main/java/team/aliens/dms/android/data/notification/repository/NotificationRepositoryImpl.kt
@@ -1,13 +1,19 @@
package team.aliens.dms.android.data.notification.repository
-import team.aliens.dms.android.network.notification.datasource.NetworkNotificationDataSource
+import team.aliens.dms.android.core.device.datastore.DeviceDataStoreDataSource
import team.aliens.dms.android.data.notification.model.Notification
import team.aliens.dms.android.data.notification.model.NotificationTopic
import team.aliens.dms.android.data.notification.model.NotificationTopicGroup
+import team.aliens.dms.android.data.notification.model.toModel
+import team.aliens.dms.android.network.notification.datasource.NetworkNotificationDataSource
+import team.aliens.dms.android.network.notification.model.BatchUpdateNotificationTopicRequest
+import team.aliens.dms.android.network.notification.model.SubscribeNotificationTopicRequest
+import team.aliens.dms.android.network.notification.model.UnsubscribeNotificationTopicRequest
import javax.inject.Inject
internal class NotificationRepositoryImpl @Inject constructor(
private val networkNotificationDataSource: NetworkNotificationDataSource,
+ private val deviceDataStoreDataSource: DeviceDataStoreDataSource,
) : NotificationRepository() {
override suspend fun registerDeviceNotificationToken(deviceToken: String) {
@@ -21,28 +27,42 @@ internal class NotificationRepositoryImpl @Inject constructor(
override suspend fun subscribeNotificationTopic(
deviceToken: String,
topic: NotificationTopic,
- ) {
- TODO("Not yet implemented")
- }
+ ) = networkNotificationDataSource.subscribeNotificationTopic(
+ request = SubscribeNotificationTopicRequest(
+ deviceToken = deviceToken,
+ topic = topic.name,
+ )
+ )
override suspend fun unsubscribeNotificationTopic(
deviceToken: String,
topic: NotificationTopic,
- ) {
- TODO("Not yet implemented")
- }
+ ) = networkNotificationDataSource.unsubscribeNotificationTopic(
+ request = UnsubscribeNotificationTopicRequest(
+ deviceToken = deviceToken,
+ topic = topic.name,
+ )
+ )
override suspend fun batchUpdateNotificationTopic(
subscriptions: List,
- ) {
- TODO("Not yet implemented")
- }
+ ) = networkNotificationDataSource.batchUpdateNotificationTopic(
+ request = BatchUpdateNotificationTopicRequest(
+ topics = subscriptions.toModel()
+ )
+ )
- override suspend fun fetchNotificationStatus(deviceToken: String): NotificationTopicGroup.Status {
- TODO("Not yet implemented")
- }
+ override suspend fun fetchNotificationStatus(deviceToken: String): List =
+ networkNotificationDataSource.fetchNotificationTopicStatus(deviceToken = deviceToken)
+ .toModel()
- override suspend fun fetchNotifications(): List {
- TODO("Not yet implemented")
+ override suspend fun fetchNotifications(): List =
+ networkNotificationDataSource.fetchNotifications().toModel()
+
+ override suspend fun saveDeviceToken(deviceToken: String) {
+ deviceDataStoreDataSource.storeDeviceToken(deviceToken)
}
+
+ override suspend fun getDeviceToken(): String =
+ deviceDataStoreDataSource.loadDeviceToken()
}
diff --git a/feature/build.gradle.kts b/feature/build.gradle.kts
index 933149dc5..85a230cbe 100644
--- a/feature/build.gradle.kts
+++ b/feature/build.gradle.kts
@@ -57,6 +57,7 @@ dependencies {
implementation(project(ProjectPaths.Core.UI))
implementation(project(ProjectPaths.Core.DESIGN_SYSTEM))
implementation(project(ProjectPaths.Core.FILE))
+ implementation(project(ProjectPaths.Core.NOTIFICATION))
implementation(project(ProjectPaths.DATA))
diff --git a/feature/src/main/java/team/aliens/dms/android/feature/editprofile/EditProfileImageScreen.kt b/feature/src/main/java/team/aliens/dms/android/feature/editprofile/EditProfileImageScreen.kt
index 9a5849b0d..cbc3fc323 100644
--- a/feature/src/main/java/team/aliens/dms/android/feature/editprofile/EditProfileImageScreen.kt
+++ b/feature/src/main/java/team/aliens/dms/android/feature/editprofile/EditProfileImageScreen.kt
@@ -32,7 +32,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.ramcosta.composedestinations.annotation.Destination
-import team.aliens.dms.android.core.designsystem.Button
+import team.aliens.dms.android.core.designsystem.ContainedButton
import team.aliens.dms.android.core.designsystem.DmsIcon
import team.aliens.dms.android.core.designsystem.DmsTheme
import team.aliens.dms.android.core.designsystem.DmsTopAppBar
@@ -117,7 +117,7 @@ internal fun EditProfileImageScreen(
}
)
Spacer(modifier = Modifier.weight(1f))
- Button(
+ ContainedButton(
modifier = Modifier
.fillMaxWidth()
.horizontalPadding()
diff --git a/feature/src/main/java/team/aliens/dms/android/feature/findid/FindIdScreen.kt b/feature/src/main/java/team/aliens/dms/android/feature/findid/FindIdScreen.kt
index 3ae500ae4..c34ec514d 100644
--- a/feature/src/main/java/team/aliens/dms/android/feature/findid/FindIdScreen.kt
+++ b/feature/src/main/java/team/aliens/dms/android/feature/findid/FindIdScreen.kt
@@ -36,7 +36,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ramcosta.composedestinations.annotation.Destination
import team.aliens.dms.android.core.designsystem.AlertDialog
-import team.aliens.dms.android.core.designsystem.Button
import team.aliens.dms.android.core.designsystem.ContainedButton
import team.aliens.dms.android.core.designsystem.DmsIcon
import team.aliens.dms.android.core.designsystem.DmsTopAppBar
@@ -80,7 +79,7 @@ internal fun FindIdScreen(
},
onDismissRequest = { /* explicit blank */ },
confirmButton = {
- Button(
+ ContainedButton(
modifier = Modifier.fillMaxWidth(),
onClick = navigator::navigateUp,
) {
diff --git a/feature/src/main/java/team/aliens/dms/android/feature/main/Main.kt b/feature/src/main/java/team/aliens/dms/android/feature/main/Main.kt
index 95f0cc0a8..16ead0a7a 100644
--- a/feature/src/main/java/team/aliens/dms/android/feature/main/Main.kt
+++ b/feature/src/main/java/team/aliens/dms/android/feature/main/Main.kt
@@ -37,9 +37,9 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
-import team.aliens.dms.android.core.designsystem.Scaffold
+import team.aliens.dms.android.core.designsystem.DmsIcon
import team.aliens.dms.android.core.designsystem.DmsTheme
-import team.aliens.dms.android.core.designsystem.R
+import team.aliens.dms.android.core.designsystem.Scaffold
import team.aliens.dms.android.core.designsystem.slideInFromEnd
import team.aliens.dms.android.core.designsystem.slideInFromStart
import team.aliens.dms.android.core.designsystem.slideOutFromEnd
@@ -99,6 +99,7 @@ internal fun Main(
HomeScreen(
onChangeBottomAppBarVisibility = onChangeBottomAppBarVisibility,
onNavigateToAnnouncementList = { navController.navigateTo(MainSections.ANNOUNCEMENT_LIST.route) },
+ onNavigateToNotificationBox = mainNavigator::openNotificationBox,
)
}
@@ -168,6 +169,7 @@ internal fun Main(
onNavigateToPointHistory = mainNavigator::openPointHistory,
onNavigateToEditPassword = mainNavigator::openEditPasswordNav,
onNavigateToUnauthorizedNav = mainNavigator::openUnauthorizedNav,
+ onNavigateToNotificationSettings = mainNavigator::openSettingsNotification,
)
}
}
@@ -241,22 +243,22 @@ private enum class MainSections(
) {
HOME(
route = "home",
- iconRes = R.drawable.ic_home,
+ iconRes = DmsIcon.Home,
labelRes = team.aliens.dms.android.feature.R.string.bottom_nav_home,
),
APPLICATION(
route = "application",
- iconRes = R.drawable.ic_applicate,
+ iconRes = DmsIcon.Applicate,
labelRes = team.aliens.dms.android.feature.R.string.bottom_nav_application,
),
ANNOUNCEMENT_LIST(
route = "announcement_list",
- iconRes = R.drawable.ic_notice,
+ iconRes = DmsIcon.Notice,
labelRes = team.aliens.dms.android.feature.R.string.bottom_nav_announcement_list,
),
MY_PAGE(
route = "my_page",
- iconRes = R.drawable.ic_person,
+ iconRes = DmsIcon.Person,
labelRes = team.aliens.dms.android.feature.R.string.bottom_nav_my_page,
),
;
diff --git a/feature/src/main/java/team/aliens/dms/android/feature/main/home/HomeScreen.kt b/feature/src/main/java/team/aliens/dms/android/feature/main/home/HomeScreen.kt
index f6ec86009..ad284e665 100644
--- a/feature/src/main/java/team/aliens/dms/android/feature/main/home/HomeScreen.kt
+++ b/feature/src/main/java/team/aliens/dms/android/feature/main/home/HomeScreen.kt
@@ -32,6 +32,7 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
@@ -67,6 +68,7 @@ import org.threeten.bp.DayOfWeek
import org.threeten.bp.LocalDate
import team.aliens.dms.android.core.designsystem.ButtonDefaults
import team.aliens.dms.android.core.designsystem.DmsCalendar
+import team.aliens.dms.android.core.designsystem.DmsIcon
import team.aliens.dms.android.core.designsystem.DmsTheme
import team.aliens.dms.android.core.designsystem.DmsTopAppBar
import team.aliens.dms.android.core.designsystem.LocalToast
@@ -94,6 +96,7 @@ import team.aliens.dms.android.feature.main.home.MealCardType.BREAKFAST
import team.aliens.dms.android.feature.main.home.MealCardType.DINNER
import team.aliens.dms.android.feature.main.home.MealCardType.LUNCH
import team.aliens.dms.android.shared.date.util.now
+import java.util.UUID
import kotlin.math.absoluteValue
@OptIn(ExperimentalMaterial3Api::class)
@@ -104,6 +107,7 @@ internal fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel(),
onChangeBottomAppBarVisibility: (visible: Boolean) -> Unit,
onNavigateToAnnouncementList: () -> Unit,
+ onNavigateToNotificationBox: () -> Unit,
) {
val toast = LocalToast.current
val context = LocalContext.current
@@ -173,6 +177,15 @@ internal fun HomeScreen(
),
)
},
+ actions = {
+ IconButton(onClick = onNavigateToNotificationBox) {
+ Icon(
+ painter = painterResource(id = DmsIcon.Bell),
+ contentDescription = stringResource(id = R.string.notification_box),
+ tint = DmsTheme.colorScheme.icon,
+ )
+ }
+ },
)
},
) { padValues ->
diff --git a/feature/src/main/java/team/aliens/dms/android/feature/main/mypage/MyPageScreen.kt b/feature/src/main/java/team/aliens/dms/android/feature/main/mypage/MyPageScreen.kt
index 8b4475344..1f0fa6704 100644
--- a/feature/src/main/java/team/aliens/dms/android/feature/main/mypage/MyPageScreen.kt
+++ b/feature/src/main/java/team/aliens/dms/android/feature/main/mypage/MyPageScreen.kt
@@ -77,6 +77,7 @@ internal fun MyPageScreen(
onNavigateToEditProfileImage: () -> Unit,
onNavigateToPointHistory: (PointType) -> Unit,
onNavigateToEditPassword: () -> Unit,
+ onNavigateToNotificationSettings: () -> Unit,
onNavigateToUnauthorizedNav: () -> Unit,
) {
val viewModel: MyPageViewModel = hiltViewModel()
@@ -180,6 +181,7 @@ internal fun MyPageScreen(
modifier = Modifier.fillMaxWidth(),
onNavigateToPointHistory = onNavigateToPointHistory,
onNavigateToEditPassword = onNavigateToEditPassword,
+ onNavigateToNotificationSettings = onNavigateToNotificationSettings,
onSignOutClick = { onShouldShowSignOutDialogChange(true) },
onWithdrawalClick = { onShouldShowWithdrawDialogChange(true) },
onThemeSettingsClick = {
@@ -259,9 +261,9 @@ private fun UserInformation(
) {
AsyncImage(
modifier = Modifier
- .size(64.dp)
- .clip(CircleShape)
- .clickable(onClick = onNavigateToEditProfileImage),
+ .size(64.dp)
+ .clip(CircleShape)
+ .clickable(onClick = onNavigateToEditProfileImage),
contentScale = ContentScale.Crop,
model = profileImageUrl ?: DmsIcon.ProfileDefault,
contentDescription = stringResource(id = R.string.profile_image),
@@ -430,6 +432,7 @@ private fun Options(
modifier: Modifier = Modifier,
onNavigateToPointHistory: (PointType) -> Unit,
onNavigateToEditPassword: () -> Unit,
+ onNavigateToNotificationSettings: () -> Unit,
onSignOutClick: () -> Unit,
onWithdrawalClick: () -> Unit,
onThemeSettingsClick: () -> Unit,
@@ -444,6 +447,10 @@ private fun Options(
titleRes = R.string.my_page_edit_password,
onClick = onNavigateToEditPassword,
),
+ Option(
+ titleRes = R.string.my_page_notification_settings,
+ onClick = onNavigateToNotificationSettings,
+ ),
)
}
val signOutOption = remember {
diff --git a/feature/src/main/java/team/aliens/dms/android/feature/main/navigation/MainNavigator.kt b/feature/src/main/java/team/aliens/dms/android/feature/main/navigation/MainNavigator.kt
index 7107ff35e..ec0b673a3 100644
--- a/feature/src/main/java/team/aliens/dms/android/feature/main/navigation/MainNavigator.kt
+++ b/feature/src/main/java/team/aliens/dms/android/feature/main/navigation/MainNavigator.kt
@@ -5,10 +5,12 @@ import java.util.UUID
interface MainNavigator {
- fun openNotificationBox()
-
fun openUnauthorizedNav()
+ fun openSettingsNotification()
+
+ fun openNotificationBox()
+
fun openStudyRoomList()
fun openRemainsApplication()
diff --git a/feature/src/main/java/team/aliens/dms/android/feature/notification/NotificationBoxScreen.kt b/feature/src/main/java/team/aliens/dms/android/feature/notification/NotificationBoxScreen.kt
deleted file mode 100644
index aaa691fe6..000000000
--- a/feature/src/main/java/team/aliens/dms/android/feature/notification/NotificationBoxScreen.kt
+++ /dev/null
@@ -1,155 +0,0 @@
-package team.aliens.dms.android.feature.notification
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Icon
-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.res.painterResource
-import androidx.compose.ui.unit.dp
-import com.ramcosta.composedestinations.annotation.Destination
-import team.aliens.dms.android.core.designsystem.DmsTheme
-import team.aliens.dms.android.core.designsystem.VerticallyFadedLazyColumn
-import team.aliens.dms.android.data.notification.model.Notification
-import team.aliens.dms.android.data.notification.model.NotificationTopic
-import team.aliens.dms.android.feature.R
-import team.aliens.dms.android.feature.notification.navigation.NotificationNavigation
-
-@Destination
-@Composable
-internal fun NotificationBoxScreen(
- modifier: Modifier = Modifier,
- navigator: NotificationNavigation,
- // notificationBoxViewModel: NotificationBoxViewModel = hiltViewModel(),
-) {/*
- val uiState by notificationBoxViewModel.stateFlow.collectAsStateWithLifecycle()
-
- Column(
- modifier = modifier
- .background(DormTheme.colors.background)
- .fillMaxSize(),
- ) {
- TopBar(
- title = stringResource(R.string.my_page_check_point_history),
- onPrevious = navigator::popBackStack,
- )
- Notifications(
- modifier = Modifier.fillMaxWidth(),
- newNotifications = uiState.newNotifications,
- priorNotifications = uiState.priorNotifications,
- )
- }*/
-}
-/*
-
-// todo move to design system
-@Composable
-private fun Notifications(
- modifier: Modifier = Modifier,
- newNotifications: List,
- priorNotifications: List,
-) {
- println(newNotifications)
- println(priorNotifications)
- VerticallyFadedLazyColumn(
- modifier = modifier.fillMaxSize(),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- if (newNotifications.isNotEmpty()) {
- items(newNotifications.also { println("NEWNEW $it") }) { newNotification ->
- Notification(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- notification = newNotification,
- )
- }
- }
- if (priorNotifications.isNotEmpty()) {
- items(priorNotifications.also { println("PRIPRI $it") }) { priorNotification ->
- Notification(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- notification = priorNotification,
- )
- }
- }
- }
-}
-
-@Composable
-private fun Notification(
- modifier: Modifier = Modifier,
- notification: Notification,
-) {
- Row(
- modifier = modifier
- .dormShadow(DmsTheme.colorScheme.line)
- .fillMaxWidth()
- .background(
- color = if (!notification.read) {
- DmsTheme.colorScheme.surface
- } else {
- DmsTheme.colorScheme.surface
- },
- shape = RoundedCornerShape(10.dp),
- )
- .clip(RoundedCornerShape(10.dp))
- .dormClickable {
- // todo
- }
- .padding(
- horizontal = 12.dp,
- vertical = 8.dp,
- ),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalAlignment = Alignment.Top,
- ) {
- Icon(
- painter = painterResource(
- when (notification.topic) {
- NotificationTopic.NOTICE -> R.drawable.ic_notice
- },
- ),
- contentDescription = null,
- tint = if (!notification.read) {
- DmsTheme.colorScheme.primary
- } else {
- DmsTheme.colorScheme.onBackground
- },
- )
- Column(
- modifier = Modifier
- .weight(1f)
- .padding(vertical = 4.dp),
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- Body3(
- text = notification.title,
- )
- Caption(
- text = notification.content,
- )
- }
- Caption(
- text = createdAtDateToFormattedString(notification.createdAt),
- )
- }
-}
-
-@Composable
-private fun createdAtDateToFormattedString(createdAt: String): String {
- // TODO
- return "8일 전"
-}
-*/
diff --git a/feature/src/main/java/team/aliens/dms/android/feature/notification/NotificationBoxViewModel.kt b/feature/src/main/java/team/aliens/dms/android/feature/notification/NotificationBoxViewModel.kt
deleted file mode 100644
index d433cd4ff..000000000
--- a/feature/src/main/java/team/aliens/dms/android/feature/notification/NotificationBoxViewModel.kt
+++ /dev/null
@@ -1,73 +0,0 @@
-package team.aliens.dms.android.feature.notification
-/*
-
-import androidx.lifecycle.viewModelScope
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import team.aliens.dms.android.feature._legacy.base.BaseMviViewModel
-import team.aliens.dms.android.feature._legacy.base.MviIntent
-import team.aliens.dms.android.feature._legacy.base.MviSideEffect
-import team.aliens.dms.android.feature._legacy.base.MviState
-import team.aliens.dms.android.domain.model.notification.Notification
-import team.aliens.dms.android.domain.usecase.notification.FetchNotificationsUseCase
-import javax.inject.Inject
-
-@HiltViewModel
-internal class NotificationBoxViewModel @Inject constructor(
- private val fetchNotificationsUseCase: FetchNotificationsUseCase,
-) : BaseMviViewModel(
- initialState = NotificationBoxState.initial(),
-) {
- init {
- fetchNotifications()
- }
-
- private fun fetchNotifications() {
- viewModelScope.launch(Dispatchers.IO) {
- fetchNotificationsUseCase().onSuccess { fetchedNotifications ->
- val newNotificationsAndPriorNotifications =
- fetchedNotifications.extractNewNotificationsAndPriorNotifications()
- reduce(
- newState = stateFlow.value.copy(
- newNotifications = newNotificationsAndPriorNotifications.first,
- priorNotifications = newNotificationsAndPriorNotifications.second,
- ),
- )
- }.onFailure {
- postSideEffect(NotificationBoxSideEffect.FetchingNotificationsFailed)
- }
- }
- }
-
- */
-/**
- * @return [Pair.first] new notifications
- * @return [Pair.second] prior notifications
- *//*
-
- private fun List.extractNewNotificationsAndPriorNotifications(): Pair, List> {
- val newNotifications = this.filter { it.read }
- val priorNotifications = this.filter { !it.read }
- return Pair(newNotifications, priorNotifications)
- }
-}
-
-internal sealed class NotificationBoxIntent : MviIntent
-
-internal data class NotificationBoxState(
- val newNotifications: List,
- val priorNotifications: List,
-) : MviState {
- companion object {
- fun initial() = NotificationBoxState(
- newNotifications = emptyList(),
- priorNotifications = emptyList(),
- )
- }
-}
-
-internal sealed class NotificationBoxSideEffect : MviSideEffect {
- object FetchingNotificationsFailed : NotificationBoxSideEffect()
-}
-*/
diff --git a/feature/src/main/java/team/aliens/dms/android/feature/notification/box/NotificationBoxScreen.kt b/feature/src/main/java/team/aliens/dms/android/feature/notification/box/NotificationBoxScreen.kt
new file mode 100644
index 000000000..82c7a6a71
--- /dev/null
+++ b/feature/src/main/java/team/aliens/dms/android/feature/notification/box/NotificationBoxScreen.kt
@@ -0,0 +1,232 @@
+package team.aliens.dms.android.feature.notification.box
+
+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
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.ramcosta.composedestinations.annotation.Destination
+import org.threeten.bp.LocalDateTime
+import team.aliens.dms.android.core.designsystem.DmsIcon
+import team.aliens.dms.android.core.designsystem.DmsTheme
+import team.aliens.dms.android.core.designsystem.DmsTopAppBar
+import team.aliens.dms.android.core.designsystem.LocalToast
+import team.aliens.dms.android.core.designsystem.Scaffold
+import team.aliens.dms.android.core.designsystem.shadow
+import team.aliens.dms.android.core.ui.PaddingDefaults
+import team.aliens.dms.android.core.ui.collectInLaunchedEffectWithLifecycle
+import team.aliens.dms.android.core.ui.horizontalPadding
+import team.aliens.dms.android.core.ui.topPadding
+import team.aliens.dms.android.data.notification.model.Notification
+import team.aliens.dms.android.data.notification.model.NotificationTopic
+import team.aliens.dms.android.feature.R
+import team.aliens.dms.android.feature.notification.navigation.NotificationBoxNavigator
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Destination
+@Composable
+internal fun NotificationBoxScreen(
+ modifier: Modifier = Modifier,
+ navigator: NotificationBoxNavigator,
+) {
+ val viewModel: NotificationBoxViewModel = hiltViewModel()
+ val uiState by viewModel.stateFlow.collectAsStateWithLifecycle()
+ val toast = LocalToast.current
+ val context = LocalContext.current
+
+ viewModel.sideEffectFlow.collectInLaunchedEffectWithLifecycle { sideEffect ->
+ when (sideEffect) {
+ NotificationBoxSideEffect.CurrentNotificationsNotFound -> toast.showErrorToast(
+ message = context.getString(R.string.notification_box_notifications_not_current)
+ )
+
+ is NotificationBoxSideEffect.MoveToDetail -> {
+ navigator.openNoticeDetails(noticeId = sideEffect.detailId)
+ }
+ }
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ DmsTopAppBar(
+ title = { Text(text = stringResource(id = R.string.notification_box)) },
+ navigationIcon = {
+ IconButton(navigator::navigateUp) {
+ Icon(
+ painter = painterResource(id = DmsIcon.Back),
+ contentDescription = stringResource(id = R.string.top_bar_back_button),
+ )
+ }
+ },
+ )
+ },
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(DmsTheme.colorScheme.surface)
+ .padding(paddingValues),
+ ) {
+ if (uiState.notifications.isEmpty()) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = stringResource(id = R.string.notification_box_not_exist),
+ style = DmsTheme.typography.body2,
+ color = DmsTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ } else {
+ NotificationList(
+ viewModel = viewModel,
+ notifications = uiState.notifications,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun NotificationList(
+ modifier: Modifier = Modifier,
+ viewModel: NotificationBoxViewModel,
+ notifications: List,
+) {
+ val scrollState = rememberScrollState()
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(scrollState)
+ .topPadding(),
+ verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium),
+ ) {
+ NotificationListLayout(
+ viewModel = viewModel,
+ isRead = false,
+ notifications = notifications.filter { !it.isRead },
+ )
+ NotificationListLayout(
+ viewModel = viewModel,
+ isRead = true,
+ notifications = notifications.filter { it.isRead },
+ )
+ }
+}
+
+@Composable
+private fun NotificationListLayout(
+ modifier: Modifier = Modifier,
+ viewModel: NotificationBoxViewModel,
+ isRead: Boolean,
+ notifications: List,
+) {
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .horizontalPadding(),
+ verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium),
+ ) {
+ if (notifications.isNotEmpty()) {
+ Text(
+ text = stringResource(id = if (isRead) R.string.notification_box_read else R.string.notification_box_not_read),
+ style = DmsTheme.typography.caption,
+ color = DmsTheme.colorScheme.onSurfaceVariant,
+ )
+ notifications.forEach { notification ->
+ NotificationCard(
+ modifier = Modifier.shadow(),
+ isRead = isRead,
+ notification = notification,
+ onNavigateToNoticeDetails = {
+ viewModel.postIntent(
+ NotificationBoxIntent.DetailNotification(
+ notification
+ )
+ )
+ },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun NotificationCard(
+ modifier: Modifier = Modifier,
+ isRead: Boolean,
+ notification: Notification,
+ onNavigateToNoticeDetails: () -> Unit,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(DmsTheme.colorScheme.surface)
+ .clickable(
+ enabled = notification.topic == NotificationTopic.NOTICE,
+ onClick = onNavigateToNoticeDetails,
+ )
+ .padding(
+ horizontal = PaddingDefaults.Medium,
+ vertical = PaddingDefaults.Small,
+ ),
+ ) {
+ Icon(
+ painter = painterResource(id = DmsIcon.BlueNotice),
+ contentDescription = stringResource(id = R.string.notice),
+ tint = if (isRead) {
+ DmsTheme.colorScheme.onBackground
+ } else {
+ DmsTheme.colorScheme.primary
+ }
+ )
+ Spacer(modifier = Modifier.width(PaddingDefaults.Small))
+ Column {
+ Text(
+ text = notification.title,
+ style = DmsTheme.typography.body3,
+ )
+ Text(
+ text = notification.content,
+ style = DmsTheme.typography.caption,
+ )
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ Text(
+ text = notification.createdAt.text,
+ style = DmsTheme.typography.caption,
+ )
+ }
+}
+
+private val LocalDateTime.text: String
+ @Composable inline get() = stringResource(
+ id = R.string.notification_box_format_time,
+ this.monthValue,
+ this.dayOfMonth,
+ )
diff --git a/feature/src/main/java/team/aliens/dms/android/feature/notification/box/NotificationBoxViewModel.kt b/feature/src/main/java/team/aliens/dms/android/feature/notification/box/NotificationBoxViewModel.kt
new file mode 100644
index 000000000..8b27db822
--- /dev/null
+++ b/feature/src/main/java/team/aliens/dms/android/feature/notification/box/NotificationBoxViewModel.kt
@@ -0,0 +1,79 @@
+package team.aliens.dms.android.feature.notification.box
+
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import team.aliens.dms.android.core.ui.mvi.BaseMviViewModel
+import team.aliens.dms.android.core.ui.mvi.Intent
+import team.aliens.dms.android.core.ui.mvi.SideEffect
+import team.aliens.dms.android.core.ui.mvi.UiState
+import team.aliens.dms.android.data.notification.model.Notification
+import team.aliens.dms.android.data.notification.model.NotificationTopic
+import team.aliens.dms.android.data.notification.repository.NotificationRepository
+import java.util.UUID
+import javax.inject.Inject
+
+@HiltViewModel
+internal class NotificationBoxViewModel @Inject constructor(
+ private val notificationRepository: NotificationRepository,
+) : BaseMviViewModel(
+ initialState = NotificationBoxUiState.initial()
+) {
+
+ init {
+ fetchNotifications()
+ }
+
+ override fun processIntent(intent: NotificationBoxIntent) {
+ when(intent) {
+ is NotificationBoxIntent.DetailNotification -> detailNotification(intent.notification)
+ }
+ }
+
+ private fun fetchNotifications() {
+ viewModelScope.launch(Dispatchers.IO) {
+ runCatching {
+ notificationRepository.fetchNotifications()
+ }.onSuccess { notifications ->
+ reduce(newState = stateFlow.value.copy(notifications = notifications))
+ }.onFailure {
+ postSideEffect(NotificationBoxSideEffect.CurrentNotificationsNotFound)
+ }
+ }
+ }
+
+ private fun detailNotification(notification: Notification) {
+ when(notification.topic) {
+ NotificationTopic.NOTICE -> {
+ postSideEffect(NotificationBoxSideEffect.MoveToDetail(notification.linkId))
+ }
+ else -> {
+ // 처리 해야 할 작업 없음
+ }
+ }
+ }
+}
+
+internal data class NotificationBoxUiState(
+ val notifications: List,
+ val topic: NotificationTopic,
+) : UiState() {
+ companion object {
+ fun initial(): NotificationBoxUiState {
+ return NotificationBoxUiState(
+ notifications = listOf(),
+ topic = NotificationTopic.POINT,
+ )
+ }
+ }
+}
+
+internal sealed class NotificationBoxIntent : Intent() {
+ class DetailNotification(val notification: Notification): NotificationBoxIntent()
+}
+
+internal sealed class NotificationBoxSideEffect : SideEffect() {
+ data object CurrentNotificationsNotFound: NotificationBoxSideEffect()
+ data class MoveToDetail(val detailId: UUID): NotificationBoxSideEffect()
+}
diff --git a/feature/src/main/java/team/aliens/dms/android/feature/notification/navigation/NotificationBoxNavigator.kt b/feature/src/main/java/team/aliens/dms/android/feature/notification/navigation/NotificationBoxNavigator.kt
new file mode 100644
index 000000000..657b600e3
--- /dev/null
+++ b/feature/src/main/java/team/aliens/dms/android/feature/notification/navigation/NotificationBoxNavigator.kt
@@ -0,0 +1,9 @@
+package team.aliens.dms.android.feature.notification.navigation
+
+import java.util.UUID
+
+interface NotificationBoxNavigator {
+ fun navigateUp()
+
+ fun openNoticeDetails(noticeId: UUID)
+}
diff --git a/feature/src/main/java/team/aliens/dms/android/feature/notification/navigation/NotificationNavigation.kt b/feature/src/main/java/team/aliens/dms/android/feature/notification/navigation/NotificationSettingsNavigator.kt
similarity index 67%
rename from feature/src/main/java/team/aliens/dms/android/feature/notification/navigation/NotificationNavigation.kt
rename to feature/src/main/java/team/aliens/dms/android/feature/notification/navigation/NotificationSettingsNavigator.kt
index d8e83b59e..caeaaba85 100644
--- a/feature/src/main/java/team/aliens/dms/android/feature/notification/navigation/NotificationNavigation.kt
+++ b/feature/src/main/java/team/aliens/dms/android/feature/notification/navigation/NotificationSettingsNavigator.kt
@@ -1,5 +1,5 @@
package team.aliens.dms.android.feature.notification.navigation
-interface NotificationNavigation {
+interface NotificationSettingsNavigator {
fun navigateUp()
}
diff --git a/feature/src/main/java/team/aliens/dms/android/feature/notification/settings/NotificationSettingsScreen.kt b/feature/src/main/java/team/aliens/dms/android/feature/notification/settings/NotificationSettingsScreen.kt
new file mode 100644
index 000000000..2ba8da8ff
--- /dev/null
+++ b/feature/src/main/java/team/aliens/dms/android/feature/notification/settings/NotificationSettingsScreen.kt
@@ -0,0 +1,255 @@
+package team.aliens.dms.android.feature.notification.settings
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.ramcosta.composedestinations.annotation.Destination
+import team.aliens.dms.android.core.designsystem.DmsIcon
+import team.aliens.dms.android.core.designsystem.DmsTheme
+import team.aliens.dms.android.core.designsystem.DmsTopAppBar
+import team.aliens.dms.android.core.designsystem.LocalToast
+import team.aliens.dms.android.core.designsystem.Scaffold
+import team.aliens.dms.android.core.designsystem.Switch
+import team.aliens.dms.android.core.notification.notificationPermissionGranted
+import team.aliens.dms.android.core.ui.PaddingDefaults
+import team.aliens.dms.android.core.ui.collectInLaunchedEffectWithLifecycle
+import team.aliens.dms.android.core.ui.horizontalPadding
+import team.aliens.dms.android.core.ui.topPadding
+import team.aliens.dms.android.data.notification.model.NotificationTopicGroup
+import team.aliens.dms.android.feature.R
+import team.aliens.dms.android.feature.notification.navigation.NotificationSettingsNavigator
+
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Destination
+@Composable
+internal fun NotificationSettingsScreen(
+ modifier: Modifier = Modifier,
+ navigator: NotificationSettingsNavigator,
+) {
+ val viewModel: NotificationSettingsViewModel = hiltViewModel()
+ val uiState by viewModel.stateFlow.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ val toast = LocalToast.current
+
+ viewModel.sideEffectFlow.collectInLaunchedEffectWithLifecycle { sideEffect ->
+ when (sideEffect) {
+ NotificationSettingsSideEffect.CurrentNotificationsStatusNotFound -> toast.showErrorToast(
+ message = context.getString(R.string.notification_not_current)
+ )
+
+ NotificationSettingsSideEffect.SubscribeNotificationFailure -> toast.showErrorToast(
+ message = context.getString(R.string.notification_subscribe_fail)
+ )
+
+ NotificationSettingsSideEffect.UnSubscribeNotificationFailure -> toast.showErrorToast(
+ message = context.getString(R.string.notification_unsubscribe_fail)
+ )
+ }
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ DmsTopAppBar(
+ title = { Text(text = stringResource(id = R.string.notification_settings)) },
+ navigationIcon = {
+ IconButton(navigator::navigateUp) {
+ Icon(
+ painter = painterResource(id = DmsIcon.Back),
+ contentDescription = stringResource(id = R.string.top_bar_back_button),
+ )
+ }
+ },
+ )
+ },
+ ) { paddingValues ->
+ val scrollState = rememberScrollState()
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(scrollState)
+ .background(DmsTheme.colorScheme.surface)
+ .padding(paddingValues)
+ .topPadding(PaddingDefaults.Large),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ if (!notificationPermissionGranted(context)) {
+ Notice()
+ }
+ Notifications(
+ status = uiState.status,
+ viewModel = viewModel,
+ )
+ }
+ }
+}
+
+@Composable
+private fun Notice() {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .horizontalPadding()
+ .clip(DmsTheme.shapes.medium)
+ .background(DmsTheme.colorScheme.primaryContainer)
+ .padding(PaddingDefaults.Large),
+ verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium),
+ ) {
+ Icon(
+ painter = painterResource(id = DmsIcon.BlueBell),
+ contentDescription = stringResource(id = R.string.notification_notification_icon),
+ tint = DmsTheme.colorScheme.primary,
+ )
+ Text(
+ text = stringResource(id = R.string.notification_notice_off_notification),
+ style = DmsTheme.typography.body3,
+ )
+ }
+}
+
+@Composable
+private fun Notifications(
+ status: List,
+ viewModel: NotificationSettingsViewModel,
+) {
+ val noticeNotifications = remember {
+ listOf(
+ Notification(
+ titleRes = R.string.notification_channel_notice_title,
+ descriptionRes = R.string.notification_channel_notice_description,
+ ),
+ )
+ }
+ val studyRoomNotifications = remember {
+ listOf(
+ Notification(
+ titleRes = R.string.notification_channel_use_study_room_title,
+ descriptionRes = R.string.notification_channel_use_study_room_description,
+ ),
+ Notification(
+ titleRes = R.string.notification_channel_application_time_study_room_title,
+ descriptionRes = R.string.notification_channel_application_time_study_room_description,
+ ),
+ )
+ }
+ val pointNotifications = remember {
+ listOf(
+ Notification(
+ titleRes = R.string.notification_channel_point_title,
+ descriptionRes = R.string.notification_channel_point_description,
+ ),
+ )
+ }
+ val outingNotifications = remember {
+ listOf(
+ Notification(
+ titleRes = R.string.notification_channel_application_outing_title,
+ descriptionRes = R.string.notification_channel_application_outing_description,
+ ),
+ )
+ }
+ status.forEach {
+ val notifications = when (it.topicGroup) {
+ NotificationTopicGroup.NOTICE -> noticeNotifications
+ NotificationTopicGroup.STUDY_ROOM -> studyRoomNotifications
+ NotificationTopicGroup.POINT -> pointNotifications
+ NotificationTopicGroup.OUTING -> outingNotifications
+ }
+ NotificationLayout(
+ viewModel = viewModel,
+ title = it.groupName,
+ notifications = notifications,
+ topicSubscription = it.topicSubscriptions,
+ )
+ }
+}
+
+@Composable
+private fun NotificationLayout(
+ modifier: Modifier = Modifier,
+ viewModel: NotificationSettingsViewModel,
+ title: String,
+ notifications: List,
+ topicSubscription: List,
+) {
+ Column(
+ modifier = modifier
+ .horizontalPadding(),
+ verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Large),
+ ) {
+ Text(
+ text = title,
+ style = DmsTheme.typography.title3,
+ color = DmsTheme.colorScheme.surfaceVariant,
+ )
+ notifications.forEachIndexed { index, notification ->
+ val subscribed = topicSubscription[index].subscribed
+ val topic = topicSubscription[index].topic
+ var isSwitchEnabled by remember { mutableStateOf(subscribed) }
+
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column {
+ Text(
+ text = stringResource(id = notification.titleRes),
+ style = DmsTheme.typography.body2,
+ color = DmsTheme.colorScheme.onSurface,
+ )
+ Text(
+ text = stringResource(id = notification.descriptionRes),
+ style = DmsTheme.typography.caption,
+ color = DmsTheme.colorScheme.surfaceVariant,
+ )
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ Switch(
+ checked = isSwitchEnabled,
+ onCheckedChange = { isChecked ->
+ isSwitchEnabled = isChecked
+ viewModel.postIntent(
+ NotificationSettingsIntent.UpdateNotificationTopic(
+ isSubscribed = isChecked,
+ topic = topic,
+ )
+ )
+ },
+ )
+ }
+ }
+ }
+}
+
+private class Notification(
+ @StringRes val titleRes: Int,
+ @StringRes val descriptionRes: Int,
+)
diff --git a/feature/src/main/java/team/aliens/dms/android/feature/notification/settings/NotificationSettingsViewModel.kt b/feature/src/main/java/team/aliens/dms/android/feature/notification/settings/NotificationSettingsViewModel.kt
new file mode 100644
index 000000000..86a71a91a
--- /dev/null
+++ b/feature/src/main/java/team/aliens/dms/android/feature/notification/settings/NotificationSettingsViewModel.kt
@@ -0,0 +1,126 @@
+package team.aliens.dms.android.feature.notification.settings
+
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import team.aliens.dms.android.core.ui.mvi.BaseMviViewModel
+import team.aliens.dms.android.core.ui.mvi.Intent
+import team.aliens.dms.android.core.ui.mvi.SideEffect
+import team.aliens.dms.android.core.ui.mvi.UiState
+import team.aliens.dms.android.data.notification.model.NotificationTopic
+import team.aliens.dms.android.data.notification.model.NotificationTopicGroup
+import team.aliens.dms.android.data.notification.repository.NotificationRepository
+import javax.inject.Inject
+
+@HiltViewModel
+internal class NotificationSettingsViewModel @Inject constructor(
+ private val notificationRepository: NotificationRepository,
+) : BaseMviViewModel(
+ initialState = NotificationSettingsUiState.initial()
+) {
+
+ override fun processIntent(intent: NotificationSettingsIntent) {
+ when (intent) {
+ is NotificationSettingsIntent.UpdateNotificationTopic -> this.updateNotificationTopic(
+ isSubscribed = intent.isSubscribed,
+ topic = intent.topic,
+ )
+ }
+ }
+
+ init {
+ fetchNotificationsStatus()
+ }
+
+ private fun fetchNotificationsStatus() {
+ viewModelScope.launch(Dispatchers.IO) {
+ val deviceToken = notificationRepository.getDeviceToken()
+ runCatching {
+ notificationRepository.fetchNotificationStatus(deviceToken)
+ }.onSuccess {
+ reduce(newState = stateFlow.value.copy(status = it))
+ }.onFailure {
+ postSideEffect(NotificationSettingsSideEffect.CurrentNotificationsStatusNotFound)
+ }
+ }
+ }
+
+ private fun updateNotificationTopic(
+ isSubscribed: Boolean,
+ topic: NotificationTopic,
+ ) {
+ viewModelScope.launch(Dispatchers.IO) {
+ val deviceToken = notificationRepository.getDeviceToken()
+
+ if (isSubscribed) {
+ subscribeNotificationTopic(
+ deviceToken = deviceToken,
+ topic = topic,
+ )
+ } else {
+ unsubscribeNotificationTopic(
+ deviceToken = deviceToken,
+ topic = topic,
+ )
+ }
+ }
+ }
+
+ private fun subscribeNotificationTopic(
+ deviceToken: String,
+ topic: NotificationTopic,
+ ) {
+ viewModelScope.launch(Dispatchers.IO) {
+ runCatching {
+ notificationRepository.subscribeNotificationTopic(
+ deviceToken = deviceToken,
+ topic = topic,
+ )
+ }.onFailure {
+ postSideEffect(NotificationSettingsSideEffect.SubscribeNotificationFailure)
+ }
+ }
+ }
+
+ private fun unsubscribeNotificationTopic(
+ deviceToken: String,
+ topic: NotificationTopic,
+ ) {
+ viewModelScope.launch(Dispatchers.IO) {
+ runCatching {
+ notificationRepository.unsubscribeNotificationTopic(
+ deviceToken = deviceToken,
+ topic = topic,
+ )
+ }.onFailure {
+ postSideEffect(NotificationSettingsSideEffect.UnSubscribeNotificationFailure)
+ }
+ }
+ }
+}
+
+internal data class NotificationSettingsUiState(
+ val status: List,
+) : UiState() {
+ companion object {
+ fun initial(): NotificationSettingsUiState {
+ return NotificationSettingsUiState(
+ status = listOf(),
+ )
+ }
+ }
+}
+
+internal sealed class NotificationSettingsIntent : Intent() {
+ class UpdateNotificationTopic(
+ val isSubscribed: Boolean,
+ val topic: NotificationTopic,
+ ) : NotificationSettingsIntent()
+}
+
+internal sealed class NotificationSettingsSideEffect : SideEffect() {
+ data object CurrentNotificationsStatusNotFound : NotificationSettingsSideEffect()
+ data object SubscribeNotificationFailure : NotificationSettingsSideEffect()
+ data object UnSubscribeNotificationFailure : NotificationSettingsSideEffect()
+}
diff --git a/feature/src/main/java/team/aliens/dms/android/feature/outing/OutingApplicationScreen.kt b/feature/src/main/java/team/aliens/dms/android/feature/outing/OutingApplicationScreen.kt
index 1e494522f..d963e62c0 100644
--- a/feature/src/main/java/team/aliens/dms/android/feature/outing/OutingApplicationScreen.kt
+++ b/feature/src/main/java/team/aliens/dms/android/feature/outing/OutingApplicationScreen.kt
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.items
@@ -53,7 +54,7 @@ import com.ramcosta.composedestinations.annotation.Destination
import kotlinx.coroutines.launch
import org.threeten.bp.DayOfWeek
import org.threeten.bp.LocalTime
-import team.aliens.dms.android.core.designsystem.Button
+import team.aliens.dms.android.core.designsystem.ContainedButton
import team.aliens.dms.android.core.designsystem.DmsTheme
import team.aliens.dms.android.core.designsystem.DmsTopAppBar
import team.aliens.dms.android.core.designsystem.LocalToast
@@ -126,7 +127,7 @@ fun OutingApplicationScreen(
endTime = uiState.selectedOutingEndTime,
)
Spacer(modifier = Modifier.height(20.dp))
- Button(
+ ContainedButton(
modifier = Modifier
.fillMaxWidth()
.horizontalPadding()
@@ -236,7 +237,7 @@ fun OutingApplicationScreen(
},
selectedOnly = true,
)
- Button(
+ ContainedButton(
modifier = Modifier
.fillMaxWidth()
.horizontalPadding()
@@ -484,12 +485,14 @@ fun OutingApplicationScreen(
)
}
- Button(
+ ContainedButton(
modifier = Modifier
.fillMaxWidth()
.horizontalPadding()
- .bottomPadding(),
+ .bottomPadding()
+ .imePadding(),
onClick = { viewModel.postIntent(OutingIntent.ApplyOuting) },
+ enabled = uiState.selectedOutingType != null,
) {
Text(text = stringResource(id = R.string.outing_do_application))
}
diff --git a/feature/src/main/java/team/aliens/dms/android/feature/outing/OutingStatusScreen.kt b/feature/src/main/java/team/aliens/dms/android/feature/outing/OutingStatusScreen.kt
index 50e4c78c1..9885827db 100644
--- a/feature/src/main/java/team/aliens/dms/android/feature/outing/OutingStatusScreen.kt
+++ b/feature/src/main/java/team/aliens/dms/android/feature/outing/OutingStatusScreen.kt
@@ -32,8 +32,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ramcosta.composedestinations.annotation.Destination
import team.aliens.dms.android.core.designsystem.AlertDialog
-import team.aliens.dms.android.core.designsystem.Button
import team.aliens.dms.android.core.designsystem.ButtonDefaults
+import team.aliens.dms.android.core.designsystem.ContainedButton
import team.aliens.dms.android.core.designsystem.DmsTheme
import team.aliens.dms.android.core.designsystem.DmsTopAppBar
import team.aliens.dms.android.core.designsystem.LocalToast
@@ -148,7 +148,7 @@ fun OutingStatusScreen(
)
}
if (uiState.currentAppliedOutingApplication == null) {
- Button(
+ ContainedButton(
modifier = Modifier
.fillMaxWidth()
.bottomPadding()
diff --git a/feature/src/main/java/team/aliens/dms/android/feature/signin/SignInScreen.kt b/feature/src/main/java/team/aliens/dms/android/feature/signin/SignInScreen.kt
index 29ee1147f..5aec20088 100644
--- a/feature/src/main/java/team/aliens/dms/android/feature/signin/SignInScreen.kt
+++ b/feature/src/main/java/team/aliens/dms/android/feature/signin/SignInScreen.kt
@@ -1,5 +1,13 @@
package team.aliens.dms.android.feature.signin
+import android.Manifest
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.provider.Settings
+import androidx.activity.compose.ManagedActivityResultLauncher
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -19,6 +27,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -37,15 +46,18 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ramcosta.composedestinations.annotation.Destination
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import team.aliens.dms.android.core.designsystem.ContainedButton
-import team.aliens.dms.android.core.designsystem.Scaffold
import team.aliens.dms.android.core.designsystem.DmsTheme
import team.aliens.dms.android.core.designsystem.DmsTopAppBar
import team.aliens.dms.android.core.designsystem.LocalToast
+import team.aliens.dms.android.core.designsystem.Scaffold
import team.aliens.dms.android.core.designsystem.TextField
import team.aliens.dms.android.core.designsystem.clickable
+import team.aliens.dms.android.core.notification.notificationPermissionGranted
import team.aliens.dms.android.core.ui.Banner
import team.aliens.dms.android.core.ui.BannerDefaults
import team.aliens.dms.android.core.ui.DefaultHorizontalSpace
@@ -76,6 +88,23 @@ internal fun SignInScreen(
val toast = LocalToast.current
val context = LocalContext.current
+ val settingLauncher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {}
+ val requestPermissionLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
+ if (!it) {
+ CoroutineScope(Dispatchers.IO).launch {
+ toast.showErrorToast(message = context.getString(R.string.sign_in_notification_revoked))
+ }
+
+ settingLauncher.launch(
+ Intent(
+ Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ Uri.fromParts("package", context.packageName, null),
+ ),
+ )
+ }
+ }
viewModel.sideEffectFlow.collectInLaunchedEffectWithLifecycle { sideEffect ->
when (sideEffect) {
@@ -85,11 +114,16 @@ internal fun SignInScreen(
}
}
+ LaunchedEffect(Unit) {
+ if (!notificationPermissionGranted(context)) {
+ requestPermissionLauncher.requestNotificationPermission()
+ }
+ }
+
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
DmsTopAppBar(
- title = { /* explicit blank */ },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
),
@@ -336,3 +370,9 @@ private fun SignInPreview() {
}
}
}
+
+private fun ManagedActivityResultLauncher.requestNotificationPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+}
diff --git a/feature/src/main/java/team/aliens/dms/android/feature/signin/SignInViewModel.kt b/feature/src/main/java/team/aliens/dms/android/feature/signin/SignInViewModel.kt
index 1a70fb941..1ad900b31 100644
--- a/feature/src/main/java/team/aliens/dms/android/feature/signin/SignInViewModel.kt
+++ b/feature/src/main/java/team/aliens/dms/android/feature/signin/SignInViewModel.kt
@@ -13,15 +13,23 @@ import team.aliens.dms.android.data.auth.exception.BadRequestException
import team.aliens.dms.android.data.auth.exception.PasswordMismatchException
import team.aliens.dms.android.data.auth.exception.UserNotFoundException
import team.aliens.dms.android.data.auth.repository.AuthRepository
+import team.aliens.dms.android.data.notification.repository.NotificationRepository
import javax.inject.Inject
@HiltViewModel
internal class SignInViewModel @Inject constructor(
private val authRepository: AuthRepository,
+ private val notificationRepository: NotificationRepository,
) : BaseMviViewModel(
initialState = SignInUiState.initial(),
) {
+ private lateinit var deviceToken: String
+
+ init {
+ getDeviceToken()
+ }
+
override fun processIntent(intent: SignInIntent) {
when (intent) {
is SignInIntent.UpdateId -> updateId(intent.id)
@@ -30,6 +38,16 @@ internal class SignInViewModel @Inject constructor(
}
}
+ private fun getDeviceToken() {
+ viewModelScope.launch(Dispatchers.IO) {
+ runCatching {
+ notificationRepository.getDeviceToken()
+ }.onSuccess {
+ deviceToken = it
+ }
+ }
+ }
+
private fun updateId(accountId: String): Boolean = reduce(
newState = stateFlow.value.copy(
accountId = accountId,
@@ -65,6 +83,7 @@ internal class SignInViewModel @Inject constructor(
authRepository.signIn(
accountId = uiState.accountId.trim(),
password = uiState.password.trim(),
+ deviceToken = deviceToken,
)
}.onSuccess {
postSideEffect(SignInSideEffect.Success)
diff --git a/feature/src/main/java/team/aliens/dms/android/feature/signup/7_SetProfileImageScreen.kt b/feature/src/main/java/team/aliens/dms/android/feature/signup/7_SetProfileImageScreen.kt
index 616030234..139d31752 100644
--- a/feature/src/main/java/team/aliens/dms/android/feature/signup/7_SetProfileImageScreen.kt
+++ b/feature/src/main/java/team/aliens/dms/android/feature/signup/7_SetProfileImageScreen.kt
@@ -33,7 +33,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.ramcosta.composedestinations.annotation.Destination
-import team.aliens.dms.android.core.designsystem.Button
+import team.aliens.dms.android.core.designsystem.ContainedButton
import team.aliens.dms.android.core.designsystem.DmsIcon
import team.aliens.dms.android.core.designsystem.DmsTheme
import team.aliens.dms.android.core.designsystem.DmsTopAppBar
@@ -141,7 +141,7 @@ internal fun SetProfileImageScreen(
style = DmsTheme.typography.button,
)
Spacer(modifier = Modifier.height(20.dp))
- Button(
+ ContainedButton(
modifier = Modifier
.fillMaxWidth()
.horizontalPadding()
diff --git a/feature/src/main/java/team/aliens/dms/android/feature/studyroom/details/StudyRoomDetailsScreen.kt b/feature/src/main/java/team/aliens/dms/android/feature/studyroom/details/StudyRoomDetailsScreen.kt
index 22406605f..abe55f89e 100644
--- a/feature/src/main/java/team/aliens/dms/android/feature/studyroom/details/StudyRoomDetailsScreen.kt
+++ b/feature/src/main/java/team/aliens/dms/android/feature/studyroom/details/StudyRoomDetailsScreen.kt
@@ -46,11 +46,11 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ramcosta.composedestinations.annotation.Destination
-import team.aliens.dms.android.core.designsystem.Button
import team.aliens.dms.android.core.designsystem.ButtonDefaults
-import team.aliens.dms.android.core.designsystem.Scaffold
+import team.aliens.dms.android.core.designsystem.ContainedButton
import team.aliens.dms.android.core.designsystem.DmsTheme
import team.aliens.dms.android.core.designsystem.DmsTopAppBar
+import team.aliens.dms.android.core.designsystem.Scaffold
import team.aliens.dms.android.core.designsystem.ShadowDefaults
import team.aliens.dms.android.core.ui.DefaultHorizontalSpace
import team.aliens.dms.android.core.ui.DefaultVerticalSpace
@@ -170,7 +170,7 @@ internal fun StudyRoomDetailsScreen(
)
},
)
- Button(
+ ContainedButton(
modifier = Modifier
.fillMaxWidth()
.horizontalPadding()
@@ -408,7 +408,7 @@ private fun Seat(
DmsTheme.colorScheme.onPrimary
}
- Button(
+ ContainedButton(
modifier = Modifier.size(DefaultSeatButtonSize),
shape = CircleShape,
colors = ButtonDefaults.buttonColors(
diff --git a/feature/src/main/java/team/aliens/dms/android/feature/studyroom/list/StudyRoomListScreen.kt b/feature/src/main/java/team/aliens/dms/android/feature/studyroom/list/StudyRoomListScreen.kt
index 1ee5aa81c..4c7fbf934 100644
--- a/feature/src/main/java/team/aliens/dms/android/feature/studyroom/list/StudyRoomListScreen.kt
+++ b/feature/src/main/java/team/aliens/dms/android/feature/studyroom/list/StudyRoomListScreen.kt
@@ -28,13 +28,13 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ramcosta.composedestinations.annotation.Destination
import kotlinx.coroutines.launch
-import team.aliens.dms.android.core.designsystem.Button
import team.aliens.dms.android.core.designsystem.ButtonDefaults
-import team.aliens.dms.android.core.designsystem.Scaffold
+import team.aliens.dms.android.core.designsystem.ContainedButton
import team.aliens.dms.android.core.designsystem.DmsTheme
import team.aliens.dms.android.core.designsystem.DmsTopAppBar
import team.aliens.dms.android.core.designsystem.ModalBottomSheet
import team.aliens.dms.android.core.designsystem.OutlinedButton
+import team.aliens.dms.android.core.designsystem.Scaffold
import team.aliens.dms.android.core.designsystem.VerticallyFadedLazyColumn
import team.aliens.dms.android.core.ui.DefaultHorizontalSpace
import team.aliens.dms.android.core.ui.DefaultVerticalSpace
@@ -86,7 +86,7 @@ internal fun StudyRoomListScreen(
val selected =
availableStudyRoomTime == uiState.selectedAvailableStudyRoomTime
if (selected) {
- Button(
+ ContainedButton(
onClick = { /* explicit blank */ },
) {
Text(
@@ -120,7 +120,7 @@ internal fun StudyRoomListScreen(
}
}
}
- Button(
+ ContainedButton(
modifier = Modifier
.fillMaxWidth()
.horizontalPadding(),
diff --git a/feature/src/main/res/values/strings.xml b/feature/src/main/res/values/strings.xml
index 09d3016ff..9922573e1 100644
--- a/feature/src/main/res/values/strings.xml
+++ b/feature/src/main/res/values/strings.xml
@@ -70,6 +70,7 @@
잘못된 형식입니다
알림 기기 등록에 실패하였습니다
+ 알림을 받기 위해서 권한을 허용해주세요
회원가입
회원가입 종료
@@ -213,6 +214,7 @@
프로필 수정
상벌점 내역 확인
비밀번호 변경
+ 알림 설정
상벌점 확인
%d 점
%d월 %d일
@@ -296,9 +298,42 @@
ch-dms-notification
gr-dms-notification-notice
- 공지사항
- 공지사항
- 공지사항에 관한 알림을 설정합니다.
+ 알림 설정
+ 공지
+ 자습실
+ 상벌점
+ 외출
+ 공지 알림
+ 기숙사 공지에 대한 알림입니다.
+ 이용 시간 알림
+ 자습실 이용 시작 10분 전에 알림을 받아요.
+ 신청 시간 알림
+ 자습실 신청 시간을 알리는 알림입니다.
+ 상벌점 알림
+ 상벌점이 부여되었을 때 알림을 받아요.
+ 외출 신청 알림
+ 동행자로 외출 신청되었을 때 알림을 받아요.
+
+ 알림 설정을 가져오지 못했어요
+ 알림이 구독되었습니다
+ 알림 구독을 실패했어요
+ 알림이 구독 취소 되었어요
+ 알림 구독 취소를 실패했어요
+
+ 알림 아이콘
+ 현재 기기 설정에서 DMS 알림이 꺼져 있습니다.\n알림을 받으려면 기기 설정에서\nDMS 알림을 활성화해주세요.
+
+ 알림함
+
+ 읽음
+ 읽지 않음
+
+ 알림이 존재하지 않습니다.
+
+ %d월 %d일
+
+ 알림을 가져오지 못했어요
+
학년
반
번호
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 1d7124e6f..03de15b83 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -8,10 +8,18 @@ appUpdate = "2.1.0"
core = "1.12.0"
datastorePreferences = "1.0.0"
espresso = "3.5.1"
+firebaseAnalytics = "22.0.0"
+firebaseBom = "33.0.0"
+firebaseMessaging = "24.0.0"
+googleServices = "4.4.1"
+glanceAppwidget = "1.1.0"
+hiltWork = "1.2.0"
javaxInject = "1"
junit = "4.13.2"
junitAndroid = "1.1.5"
coroutines = "1.7.3"
+kotlinStdlib = "2.0.0"
+kotlinxSerializationJson = "1.6.3"
lifecycle = "2.7.0"
material = "1.11.0"
materialCompose = "1.2.0"
@@ -19,7 +27,7 @@ agp = "8.2.2"
kotlinAndroid = "1.9.20"
kotlinJvm = "1.9.20"
hilt = "2.48"
-google = "4.4.1"
+google = "4.4.2"
ksp = "1.9.10-1.0.13"
moshiKotlin = "1.14.0"
okhttp = "4.12.0"
@@ -33,12 +41,18 @@ hiltNavigation = "1.1.0"
coil = "2.4.0"
compileSdk = "34"
minSdk = "23"
-targetSdk = "33"
+targetSdk = "34"
java = "17"
junitKtx = "1.1.5"
+workRuntime = "2.9.0"
+serialization = "2.0.0"
+firebase-crashlytics = "2.9.9"
[libraries]
accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" }
+androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glanceAppwidget" }
+androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltWork" }
+androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" }
androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
@@ -59,9 +73,15 @@ androidx-compose-material = { group = "androidx.compose.material3", name = "mate
androidx-compose-material-window = { group = "androidx.compose.material3", name = "material3-window-size-class-android", version.ref = "materialCompose" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigation" }
+androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntime" }
app-update = { module = "com.google.android.play:app-update-ktx", version.ref = "appUpdate" }
composeDestinations = { group = "io.github.raamcosta.compose-destinations", name = "animations-core", version.ref = "composeDestination" }
composeDestinations-ksp = { group = "io.github.raamcosta.compose-destinations", name = "ksp", version.ref = "composeDestination" }
+firebase-analytics = { module = "com.google.firebase:firebase-analytics", version.ref = "firebaseAnalytics" }
+firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" }
+firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" }
+firebase-messaging = { module = "com.google.firebase:firebase-messaging", version.ref = "firebaseMessaging" }
+google-services = { module = "com.google.gms:google-services", version.ref = "googleServices" }
javax-inject = { module = "javax.inject:javax.inject", version.ref = "javaxInject" }
# Deprecated
@@ -69,6 +89,8 @@ junit = { module = "junit:junit", version.ref = "junit" }
coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
+kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlinStdlib" }
+kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
material = { module = "com.google.android.material:material", version.ref = "material" }
moshi = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshiKotlin" }
moshi-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshiKotlin" }
@@ -92,3 +114,5 @@ hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
googleServices = { id = "com.google.gms.google-services", version.ref = "google" }
jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlinJvm" }
+serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" }
+firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebase-crashlytics" }
diff --git a/network/src/androidTest/java/team/aliens/dms/android/network/auth/apiservice/AuthApiServiceTest.kt b/network/src/androidTest/java/team/aliens/dms/android/network/auth/apiservice/AuthApiServiceTest.kt
index 17acc687c..5d414c7cc 100644
--- a/network/src/androidTest/java/team/aliens/dms/android/network/auth/apiservice/AuthApiServiceTest.kt
+++ b/network/src/androidTest/java/team/aliens/dms/android/network/auth/apiservice/AuthApiServiceTest.kt
@@ -56,6 +56,7 @@ class AuthApiServiceTest {
val request = SignInRequest(
accountId = "student",
password = "rhqmffls13!",
+ deviceToken = "",
)
val response = Gson().fromJson(
context.readJsonFromRawResources(R.raw.sign_in_response),
diff --git a/network/src/main/java/team/aliens/dms/android/network/auth/model/SignInRequest.kt b/network/src/main/java/team/aliens/dms/android/network/auth/model/SignInRequest.kt
index 55274cfc6..f81d368b1 100644
--- a/network/src/main/java/team/aliens/dms/android/network/auth/model/SignInRequest.kt
+++ b/network/src/main/java/team/aliens/dms/android/network/auth/model/SignInRequest.kt
@@ -5,4 +5,5 @@ import com.google.gson.annotations.SerializedName
data class SignInRequest(
@SerializedName("account_id") val accountId: String,
@SerializedName("password") val password: String,
+ @SerializedName("device_token") val deviceToken: String,
)
diff --git a/network/src/main/java/team/aliens/dms/android/network/file/di/ApiServiceModule.kt b/network/src/main/java/team/aliens/dms/android/network/file/di/ApiServiceModule.kt
index 95ffccd68..8acc82c11 100644
--- a/network/src/main/java/team/aliens/dms/android/network/file/di/ApiServiceModule.kt
+++ b/network/src/main/java/team/aliens/dms/android/network/file/di/ApiServiceModule.kt
@@ -5,7 +5,6 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
-import retrofit2.create
import team.aliens.dms.android.core.network.di.GlobalRetrofitClient
import team.aliens.dms.android.network.file.apiservice.FileApiService
import javax.inject.Singleton
diff --git a/network/src/main/java/team/aliens/dms/android/network/notification/apiservice/NotificationApiService.kt b/network/src/main/java/team/aliens/dms/android/network/notification/apiservice/NotificationApiService.kt
index 7865f79c9..78aace191 100644
--- a/network/src/main/java/team/aliens/dms/android/network/notification/apiservice/NotificationApiService.kt
+++ b/network/src/main/java/team/aliens/dms/android/network/notification/apiservice/NotificationApiService.kt
@@ -3,11 +3,12 @@ package team.aliens.dms.android.network.notification.apiservice
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
+import retrofit2.http.HTTP
import retrofit2.http.PATCH
import retrofit2.http.POST
+import retrofit2.http.Query
import team.aliens.dms.android.network.notification.model.BatchUpdateNotificationTopicRequest
import team.aliens.dms.android.network.notification.model.CancelFcmDeviceTokenRegistrationRequest
-import team.aliens.dms.android.network.notification.model.FetchNotificationTopicStatusRequest
import team.aliens.dms.android.network.notification.model.FetchNotificationTopicStatusResponse
import team.aliens.dms.android.network.notification.model.FetchNotificationsResponse
import team.aliens.dms.android.network.notification.model.RegisterFcmDeviceTokenRequest
@@ -17,22 +18,34 @@ import team.aliens.dms.android.network.notification.model.UnsubscribeNotificatio
internal interface NotificationApiService {
@POST("/notifications/tokens")
- suspend fun registerFcmDeviceToken(@Body request: RegisterFcmDeviceTokenRequest)
+ suspend fun registerFcmDeviceToken(
+ @Body request: RegisterFcmDeviceTokenRequest
+ )
@DELETE("/notifications/token")
- suspend fun cancelFcmDeviceTokenRegistration(@Body request: CancelFcmDeviceTokenRegistrationRequest)
+ suspend fun cancelFcmDeviceTokenRegistration(
+ @Body request: CancelFcmDeviceTokenRegistrationRequest
+ )
@POST("/notifications/topic")
- suspend fun subscribeNotificationTopic(@Body request: SubscribeNotificationTopicRequest)
+ suspend fun subscribeNotificationTopic(
+ @Body request: SubscribeNotificationTopicRequest
+ )
- @DELETE("/notifications/topic")
- suspend fun unsubscribeNotificationTopic(@Body request: UnsubscribeNotificationTopicRequest)
+ @HTTP(method = "DELETE", path = "/notifications/topic", hasBody = true)
+ suspend fun unsubscribeNotificationTopic(
+ @Body request: UnsubscribeNotificationTopicRequest
+ )
@PATCH("/notifications/topic")
- suspend fun batchUpdateNotificationTopic(@Body request: BatchUpdateNotificationTopicRequest)
+ suspend fun batchUpdateNotificationTopic(
+ @Body request: BatchUpdateNotificationTopicRequest
+ )
@GET("/notifications/topic")
- suspend fun fetchNotificationTopicStatus(@Body request: FetchNotificationTopicStatusRequest): FetchNotificationTopicStatusResponse
+ suspend fun fetchNotificationTopicStatus(
+ @Query("device_token") deviceToken: String,
+ ): FetchNotificationTopicStatusResponse
@GET("/notifications")
suspend fun fetchNotifications(): FetchNotificationsResponse
diff --git a/network/src/main/java/team/aliens/dms/android/network/notification/datasource/NetworkNotificationDataSource.kt b/network/src/main/java/team/aliens/dms/android/network/notification/datasource/NetworkNotificationDataSource.kt
index 9b1b56ae6..78f12d7c5 100644
--- a/network/src/main/java/team/aliens/dms/android/network/notification/datasource/NetworkNotificationDataSource.kt
+++ b/network/src/main/java/team/aliens/dms/android/network/notification/datasource/NetworkNotificationDataSource.kt
@@ -2,7 +2,6 @@ package team.aliens.dms.android.network.notification.datasource
import team.aliens.dms.android.network.notification.model.BatchUpdateNotificationTopicRequest
import team.aliens.dms.android.network.notification.model.CancelFcmDeviceTokenRegistrationRequest
-import team.aliens.dms.android.network.notification.model.FetchNotificationTopicStatusRequest
import team.aliens.dms.android.network.notification.model.FetchNotificationTopicStatusResponse
import team.aliens.dms.android.network.notification.model.FetchNotificationsResponse
import team.aliens.dms.android.network.notification.model.RegisterFcmDeviceTokenRequest
@@ -21,7 +20,7 @@ abstract class NetworkNotificationDataSource {
abstract suspend fun batchUpdateNotificationTopic(request: BatchUpdateNotificationTopicRequest)
- abstract suspend fun fetchNotificationTopicStatus(request: FetchNotificationTopicStatusRequest): FetchNotificationTopicStatusResponse
+ abstract suspend fun fetchNotificationTopicStatus(deviceToken: String): FetchNotificationTopicStatusResponse
abstract suspend fun fetchNotifications(): FetchNotificationsResponse
}
diff --git a/network/src/main/java/team/aliens/dms/android/network/notification/datasource/NetworkNotificationDataSourceImpl.kt b/network/src/main/java/team/aliens/dms/android/network/notification/datasource/NetworkNotificationDataSourceImpl.kt
index f7b1d3c3d..9613061a4 100644
--- a/network/src/main/java/team/aliens/dms/android/network/notification/datasource/NetworkNotificationDataSourceImpl.kt
+++ b/network/src/main/java/team/aliens/dms/android/network/notification/datasource/NetworkNotificationDataSourceImpl.kt
@@ -4,7 +4,6 @@ import team.aliens.dms.android.core.network.util.handleNetworkRequest
import team.aliens.dms.android.network.notification.apiservice.NotificationApiService
import team.aliens.dms.android.network.notification.model.BatchUpdateNotificationTopicRequest
import team.aliens.dms.android.network.notification.model.CancelFcmDeviceTokenRegistrationRequest
-import team.aliens.dms.android.network.notification.model.FetchNotificationTopicStatusRequest
import team.aliens.dms.android.network.notification.model.FetchNotificationTopicStatusResponse
import team.aliens.dms.android.network.notification.model.FetchNotificationsResponse
import team.aliens.dms.android.network.notification.model.RegisterFcmDeviceTokenRequest
@@ -30,8 +29,8 @@ internal class NetworkNotificationDataSourceImpl @Inject constructor(
override suspend fun batchUpdateNotificationTopic(request: BatchUpdateNotificationTopicRequest) =
handleNetworkRequest { notificationApiService.batchUpdateNotificationTopic(request) }
- override suspend fun fetchNotificationTopicStatus(request: FetchNotificationTopicStatusRequest): FetchNotificationTopicStatusResponse =
- handleNetworkRequest { notificationApiService.fetchNotificationTopicStatus(request) }
+ override suspend fun fetchNotificationTopicStatus(deviceToken: String): FetchNotificationTopicStatusResponse =
+ handleNetworkRequest { notificationApiService.fetchNotificationTopicStatus(deviceToken) }
override suspend fun fetchNotifications(): FetchNotificationsResponse =
handleNetworkRequest { notificationApiService.fetchNotifications() }
diff --git a/network/src/main/java/team/aliens/dms/android/network/notification/di/ApiServiceModule.kt b/network/src/main/java/team/aliens/dms/android/network/notification/di/ApiServiceModule.kt
index 6486a119b..8eebaeccc 100644
--- a/network/src/main/java/team/aliens/dms/android/network/notification/di/ApiServiceModule.kt
+++ b/network/src/main/java/team/aliens/dms/android/network/notification/di/ApiServiceModule.kt
@@ -5,6 +5,7 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
+import team.aliens.dms.android.core.network.di.GlobalRetrofitClient
import team.aliens.dms.android.network.notification.apiservice.NotificationApiService
import javax.inject.Singleton
@@ -14,6 +15,7 @@ internal object ApiServiceModule {
@Provides
@Singleton
- fun provideNotificationApiService(retrofit: Retrofit): NotificationApiService =
- retrofit.create(NotificationApiService::class.java)
+ fun provideNotificationApiService(
+ @GlobalRetrofitClient retrofit: Retrofit
+ ): NotificationApiService = retrofit.create(NotificationApiService::class.java)
}
diff --git a/network/src/main/java/team/aliens/dms/android/network/notification/model/FetchNotificationTopicStatusRequest.kt b/network/src/main/java/team/aliens/dms/android/network/notification/model/FetchNotificationTopicStatusRequest.kt
deleted file mode 100644
index 1f73dabc1..000000000
--- a/network/src/main/java/team/aliens/dms/android/network/notification/model/FetchNotificationTopicStatusRequest.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package team.aliens.dms.android.network.notification.model
-
-import com.google.gson.annotations.SerializedName
-
-data class FetchNotificationTopicStatusRequest(
- @SerializedName("device_token") val deviceToken: String,
-)
diff --git a/network/src/main/java/team/aliens/dms/android/network/notification/model/FetchNotificationTopicStatusResponse.kt b/network/src/main/java/team/aliens/dms/android/network/notification/model/FetchNotificationTopicStatusResponse.kt
index 9e88df9fc..057b4820e 100644
--- a/network/src/main/java/team/aliens/dms/android/network/notification/model/FetchNotificationTopicStatusResponse.kt
+++ b/network/src/main/java/team/aliens/dms/android/network/notification/model/FetchNotificationTopicStatusResponse.kt
@@ -6,14 +6,12 @@ data class FetchNotificationTopicStatusResponse(
@SerializedName("topic_groups") val topicGroups: List,
) {
data class TopicGroupResponse(
- @SerializedName("topic_group") val topicGroup: NotificationTopicGroup,
- @SerializedName("group_title") val groupTitle: String,
+ @SerializedName("topic_group") val topicGroup: String,
+ @SerializedName("group_name") val groupName: String,
@SerializedName("topic_subscriptions") val topicSubscriptions: List,
) {
data class TopicSubscriptionResponse(
@SerializedName("topic") val topic: String,
- @SerializedName("title") val title: String,
- @SerializedName("description") val description: String,
@SerializedName("is_subscribed") val subscribed: Boolean,
)
}
diff --git a/network/src/main/java/team/aliens/dms/android/network/notification/model/FetchNotificationsResponse.kt b/network/src/main/java/team/aliens/dms/android/network/notification/model/FetchNotificationsResponse.kt
index 7881cdb9e..0e3aed216 100644
--- a/network/src/main/java/team/aliens/dms/android/network/notification/model/FetchNotificationsResponse.kt
+++ b/network/src/main/java/team/aliens/dms/android/network/notification/model/FetchNotificationsResponse.kt
@@ -1,6 +1,7 @@
package team.aliens.dms.android.network.notification.model
import com.google.gson.annotations.SerializedName
+import org.threeten.bp.LocalDateTime
import java.util.UUID
data class FetchNotificationsResponse(
@@ -13,6 +14,6 @@ data class FetchNotificationsResponse(
@SerializedName("title") val title: String,
@SerializedName("content") val content: String,
@SerializedName("created_at") val createdAt: String,
- @SerializedName("is_read") val read: Boolean,
+ @SerializedName("is_read") val isRead: Boolean,
)
}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 26fa02469..7cee04a27 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -34,6 +34,9 @@ include(":core:project")
include(":core:school")
include(":core:file")
include(":core:ui")
+include(":core:notification")
+include(":core:device")
+include(":core:widget")
include(":data")
include(":network")