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