diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 48321893..1d6d513a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,18 +7,23 @@ plugins { // Dagger-Hilt for Dependency Injection id("kotlin-kapt") - id("com.google.dagger.hilt.android") + + // Keep this plugin in app module, since app module handles all related app information + // such as App ID , etc. for using firebase services. alias(libs.plugins.google.gms.google.services) alias(libs.plugins.kotlinSerialization) + // Required from Kotlin 2.0.0 (Every module using Compose) + alias(libs.plugins.compose.compiler) + // KSP Plugin for Room Database // id("com.google.devtools.ksp") } android { namespace = "com.doyoonkim.knutice" - compileSdk = 34 + compileSdk = 35 val properties = Properties().apply { load(FileInputStream("${rootDir}/local.properties")) @@ -29,8 +34,8 @@ android { applicationId = "com.doyoonkim.knutice" minSdk = 31 targetSdk = 34 - versionCode = 16 - versionName = "1.4.1" + versionCode = 17 + versionName = "1.4.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -77,6 +82,14 @@ android { } dependencies { + implementation(projects.core.model) + implementation(projects.core.domain) + implementation(projects.core.data) + implementation(projects.core.network) + implementation(projects.core.notification) + implementation(projects.feature.main) + implementation(projects.feature.bookmark) + implementation(projects.common) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) @@ -100,39 +113,23 @@ dependencies { debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) + // Dagger + implementation(libs.dagger) + implementation(libs.dagger.android) + implementation(libs.dagger.android.support) + kapt(libs.dagger.compiler) + kapt(libs.dagger.android.processor) + implementation(libs.kotlin.serialization) // Coroutine for Android implementation(libs.kotlinx.coroutines.android) // Navigation for Compose implementation(libs.androidx.navigation.compose) - // Coil - implementation(libs.coil.compose) - - // Dagger Hilt for Dependency Injection - implementation(libs.hilt.android) - implementation(libs.androidx.hilt.navigation.compose) // With Compose Navigation - kapt(libs.hilt.android.compiler) - - // Retrofit 2 - implementation(libs.retrofit) - implementation(libs.converter.gson) - - // Jsoup HTML Parser Library - implementation(libs.jsoup) // DataStore implementation (libs.androidx.datastore.preferences) - // Room Database - implementation(libs.androidx.room.runtime) - kapt(libs.androidx.room.compiler) - // Room Database - Kotlin Extensions and Coroutine Support - implementation(libs.androidx.room.ktx) - - // Translation - implementation(libs.translate) - } // Allow references to generated code diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 61483746..f99b922c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -25,23 +25,33 @@ android:value="@string/default_notification_channel_id"/> - + + + + + + + + + - - + \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/AppNavHost.kt b/app/src/main/java/com/doyoonkim/knutice/AppNavHost.kt new file mode 100644 index 00000000..e7fddc08 --- /dev/null +++ b/app/src/main/java/com/doyoonkim/knutice/AppNavHost.kt @@ -0,0 +1,53 @@ +package com.doyoonkim.knutice + +import android.net.Uri +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import com.doyoonkim.bookmark.bookmarkServiceGraph +import com.doyoonkim.common.navigation.NavRoutes +import com.doyoonkim.main.mainServiceNavGraph + +@Composable +fun AppNavHost( + modifier: Modifier = Modifier, + contentPadding: PaddingValues, + navController: NavHostController, + viewModelFactory: ViewModelProvider.Factory +) { + NavHost( + modifier = modifier.padding( + PaddingValues( + top = contentPadding.calculateTopPadding(), +// bottom = contentPadding.calculateBottomPadding() + ) + ), + navController = navController, + startDestination = NavRoutes.Home.route + ) { + mainServiceNavGraph( + navController = navController, + viewModelFactory = viewModelFactory, + contentPadding = contentPadding, + onNoticeDetailRequested = { target -> + navController.navigate("noticeDetail/${target.nttId}/${Uri.encode(target.contentUrl)}/${target.isFabVisible}") + }, + onBookmarkServiceRequested = { + navController.navigate("bookmark/${it.noticeId}/${it.noticeTitle}/${it.noticeInfo}") + } + ) + + bookmarkServiceGraph( + navController = navController, + viewModelFactory = viewModelFactory, + contentPadding = contentPadding, + onNoticeDetailRequested = { target -> + navController.navigate("noticeDetail/${target.nttId}/${Uri.encode(target.contentUrl)}/${target.isFabVisible}") + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/MainActivity.kt b/app/src/main/java/com/doyoonkim/knutice/MainActivity.kt new file mode 100644 index 00000000..d14956ea --- /dev/null +++ b/app/src/main/java/com/doyoonkim/knutice/MainActivity.kt @@ -0,0 +1,341 @@ +package com.doyoonkim.knutice + +import android.Manifest +import android.content.Intent +import android.os.Bundle +import android.provider.Settings +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.Image +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.BottomNavigationItem +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.LaunchedEffect +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.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.view.WindowCompat +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.doyoonkim.common.navigation.NavRoutes +import com.doyoonkim.common.theme.KNUTICETheme +import com.doyoonkim.common.theme.containerBackgroundSolid +import com.doyoonkim.common.theme.subTitle +import com.doyoonkim.common.theme.title +import com.doyoonkim.common.ui.PermissionRationaleComposable +import com.doyoonkim.common.R +import com.doyoonkim.notification.local.NotificationAlarmScheduler +import javax.inject.Inject + +@OptIn(ExperimentalMaterial3Api::class) +class MainActivity : ComponentActivity() { + + @Inject lateinit var viewModelFactory: ViewModelProvider.Factory + + private val notificationAlarmScheduler by lazy { + NotificationAlarmScheduler(this) + } + + // NavController + private lateinit var navController: NavHostController + private val activity = this + + override fun onCreate(savedInstanceState: Bundle?) { + (applicationContext as MainApplication).appComponent.inject(this) + super.onCreate(savedInstanceState) + + val launchedIntent = this.intent + + WindowCompat.setDecorFitsSystemWindows(window, false) + enableEdgeToEdge() + setContent { + KNUTICETheme { + val context = LocalContext.current + var showPermissionRationale by remember { mutableStateOf(false) } + navController = rememberNavController() + + // Bottom Bar Handling + var bottomBarState = Triple(true, false, false) + val backStackEntryState by navController.currentBackStackEntryAsState() + backStackEntryState?.destination?.route.let { route -> + bottomBarState = when(route) { + NavRoutes.Home.route -> Triple(true, true, false) + NavRoutes.Bookmark.route -> Triple(true, false, true) + else -> Triple(false, false, false) + } + } + + // Permission Launcher + val requestPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + permissions.entries.forEach { + Log.d("MainServiceScreen", "${it.key}, ${it.value}") + if (it.key == Manifest.permission.SCHEDULE_EXACT_ALARM + && !notificationAlarmScheduler.canScheduleExactAlarms()) { + showPermissionRationale = true + } + } + } + + LaunchedEffect(Unit) { + // Permission check + requestPermissionLauncher.launch( + arrayOf( + Manifest.permission.POST_NOTIFICATIONS, + Manifest.permission.SCHEDULE_EXACT_ALARM + ) + ) + } + + Scaffold( + modifier = Modifier.fillMaxSize() + .background(MaterialTheme.colorScheme.containerBackgroundSolid), + topBar = { + TopAppBar( + title = { + Row( + modifier = Modifier.fillMaxWidth().wrapContentHeight(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (!bottomBarState.first) { + IconButton( + onClick = { + navController.popBackStack() + } + ) { + Image( + painter = painterResource(R.drawable.baseline_arrow_back_ios_new_24), + contentDescription = "back", + modifier = Modifier.wrapContentSize(), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.title) + ) + } + } + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.app_name), + textAlign = TextAlign.Left, + fontSize = 20.sp, + fontWeight = FontWeight.ExtraBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.title + ) + } + }, + actions = { + if (bottomBarState.first) { + IconButton( + onClick = { + navController.navigate(NavRoutes.NoticeSearch.route) + } + ) { + Image( + painter = painterResource(R.drawable.baseline_search_24), + contentDescription = "Search", + modifier = Modifier.wrapContentSize(), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.title) + ) + } + IconButton( + onClick = { + navController.navigate(NavRoutes.Settings.route) + } + ) { + Image( + painter = painterResource(R.drawable.baseline_settings_24), + contentDescription = "Settings", + modifier = Modifier.wrapContentSize(), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.title) + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.containerBackgroundSolid, + titleContentColor = MaterialTheme.colorScheme.title + ) + ) + }, + bottomBar = { + if (bottomBarState.first) { + BottomAppBar( + modifier = Modifier + .wrapContentSize() + .background(Color.Transparent) + .clip(RoundedCornerShape(15.dp)), + actions = { + // https://developer.android.com/develop/ui/compose/navigation#bottom-nav + BottomNavigationItem( + selected = bottomBarState.second, + enabled = true, + onClick = { + if (!bottomBarState.second) { + navController.navigate(NavRoutes.Home.route) { + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + }, + icon = { + Icon( + painter = painterResource(R.drawable.baseline_home_24), + contentDescription = "Main", + modifier = Modifier.padding(bottom = 5.dp) + ) + }, + label = { + Text(stringResource(R.string.bottom_bar_home)) + }, + selectedContentColor = MaterialTheme.colorScheme.title, + unselectedContentColor = MaterialTheme.colorScheme.subTitle + ) + BottomNavigationItem( + selected = bottomBarState.third, + enabled = true, + onClick = { + if (!bottomBarState.third) { + navController.navigate(NavRoutes.Bookmark.route) { + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + }, + icon = { + Icon( + painter = painterResource(R.drawable.baseline_bookmarks_24), + contentDescription = "Bookmarks", + modifier = Modifier.padding(bottom = 5.dp) + ) + }, + label = { + Text(stringResource(R.string.bottom_bar_bookmark)) + }, + selectedContentColor = MaterialTheme.colorScheme.title, + unselectedContentColor = MaterialTheme.colorScheme.subTitle + ) + } + ) + } + }, + containerColor = MaterialTheme.colorScheme.containerBackgroundSolid, + ) { contentPadding -> + + AppNavHost( + modifier = Modifier, + contentPadding = contentPadding, + navController = navController, + viewModelFactory = viewModelFactory + ) + + LaunchedEffect(Unit) { + // Intent handling (access application via onCreate call; click push notification when app is closed.) + Log.d("MainActivity", "Intent received: ${launchedIntent?.data}") + navController.handleDeepLink(launchedIntent) + } + } + + AnimatedVisibility( + visible = showPermissionRationale, + enter = scaleIn(), + exit = scaleOut() + ) { + Box( + modifier = Modifier.fillMaxSize() + .clickable { showPermissionRationale = false } + ) { + PermissionRationaleComposable( + modifier = Modifier.align(Alignment.Center).padding(start = 20.dp, end = 20.dp), + permissionName = stringResource(R.string.title_alarm_and_reminder), + rationaleTitle = stringResource(R.string.text_rationale_title), + description = stringResource(R.string.text_rationale_description) + ) { + val settingIntent = Intent( + Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM + ).apply { + this.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + this.putExtra( + "android.provider.extra.APP_PACKAGE", + context.packageName + ) + } + context.startActivity(settingIntent) + showPermissionRationale = false + } + } + } + } + } + } + + // Called when intent is being sent while the onCreate() is already called. + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + + Log.d("MainActivity", "Intent received onNewIntent: ${intent?.data}") + + // Just for extra safety. onNewIntent is called when app receives intent while the onCreate is already being called. + // Therefore, it is almost guaranteed that navController is initialized. + if (::navController.isInitialized) { + navController.handleDeepLink(intent) + } + } + + override fun onDestroy() { + super.onDestroy() + + viewModelStore.clear() + this.externalCacheDir?.delete() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/MainApplication.kt b/app/src/main/java/com/doyoonkim/knutice/MainApplication.kt index abcceceb..91ee85de 100644 --- a/app/src/main/java/com/doyoonkim/knutice/MainApplication.kt +++ b/app/src/main/java/com/doyoonkim/knutice/MainApplication.kt @@ -4,16 +4,38 @@ import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager import android.os.Build -import com.doyoonkim.knutice.fcm.PushNotificationHandler -import dagger.hilt.android.HiltAndroidApp +import com.doyoonkim.common.di.AppInjector +import com.doyoonkim.common.di.AppInjectorProvider +import com.doyoonkim.common.R +import com.doyoonkim.knutice.di.AppComponent +import com.doyoonkim.knutice.di.DaggerAppComponent +import com.doyoonkim.notification.fcm.PushNotificationService +import com.doyoonkim.notification.fcm.TokenHandler import javax.inject.Inject -@HiltAndroidApp -class MainApplication() : Application() { - @Inject lateinit var notificationHandler: PushNotificationHandler +class MainApplication() : Application(), AppInjectorProvider { + + val appComponent: AppComponent by lazy { + DaggerAppComponent.factory().create(this) + } + + override val appInjector: AppInjector = object : AppInjector { + override fun inject(target: Any) { + when(target) { + is PushNotificationService -> appComponent.inject(target) + else -> error("Unsupported Target $target") + } + } + + } + + @Inject lateinit var tokenHandler: TokenHandler override fun onCreate() { super.onCreate() + // Application-Level injection + appComponent.inject(this) + // Create channel group (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).run { createNotificationChannel( @@ -22,7 +44,7 @@ class MainApplication() : Application() { getString(R.string.inapp_notification_channel_description) ) } - notificationHandler.requestCurrentToken() + tokenHandler.handleCurrentTokenRequest() } override fun onTerminate() { diff --git a/app/src/main/java/com/doyoonkim/knutice/alarm/AlarmReceiver.kt b/app/src/main/java/com/doyoonkim/knutice/alarm/AlarmReceiver.kt deleted file mode 100644 index c680d2b2..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/alarm/AlarmReceiver.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.doyoonkim.knutice.alarm - -import android.app.NotificationManager -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent - -class AlarmReceiver : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - context?.let { - val notificationManager = - it.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val runnerNotifier = RunnerNotifier(notificationManager, context) - runnerNotifier.showNotification(intent?.getStringExtra("note")?: "") - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/alarm/AlarmScheduler.kt b/app/src/main/java/com/doyoonkim/knutice/alarm/AlarmScheduler.kt deleted file mode 100644 index e179ff44..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/alarm/AlarmScheduler.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.doyoonkim.knutice.alarm - -import android.app.PendingIntent -import com.doyoonkim.knutice.model.Bookmark - - -/** - * Alarm feature (Part of the Bookmark) - * Reference: https://medium.com/@tolgapirim25/send-notifications-at-a-specific-time-with-alarm-manager-on-android-13c7cc9d8e7a - */ -interface AlarmScheduler { - fun createPendingIntent(target: Bookmark): PendingIntent - - fun schedule(target: Bookmark) - - fun cancel(target: Bookmark) -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/alarm/NotificationAlarmScheduler.kt b/app/src/main/java/com/doyoonkim/knutice/alarm/NotificationAlarmScheduler.kt deleted file mode 100644 index 8fcaf806..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/alarm/NotificationAlarmScheduler.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.doyoonkim.knutice.alarm - -import android.app.AlarmManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.os.Build -import android.util.Log -import com.doyoonkim.knutice.model.Bookmark -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject - -class NotificationAlarmScheduler @Inject constructor( - @ApplicationContext private val context: Context -) : AlarmScheduler { - // AlarmManager Instance - private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - - override fun createPendingIntent(target: Bookmark): PendingIntent { - val intent = Intent(context, AlarmReceiver::class.java).apply { - putExtra("note", target.note) - } - - return PendingIntent.getBroadcast( - context, - target.bookmarkId, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - } - - override fun schedule(target: Bookmark) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - if (!alarmManager.canScheduleExactAlarms()) { - Log.d("NotificationAlarmScheduler", "Unable") - return - } - } - Log.d("NotificationAlarmSchedule", "Would be scheduled") - alarmManager.setExact( - AlarmManager.RTC_WAKEUP, - target.reminderSchedule, - createPendingIntent(target) - ) - - Log.d("NotificationAlarmSchedule", "Scheduled") - } - - override fun cancel(target: Bookmark) { - alarmManager.cancel( - createPendingIntent(target) - ) - } - - fun canScheduleExactAlarms(): Boolean { - return alarmManager.canScheduleExactAlarms() - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/alarm/RunnerNotifier.kt b/app/src/main/java/com/doyoonkim/knutice/alarm/RunnerNotifier.kt deleted file mode 100644 index ed7a809c..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/alarm/RunnerNotifier.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.doyoonkim.knutice.alarm - -import android.app.Notification -import android.app.NotificationManager -import android.content.Context -import androidx.core.app.NotificationCompat -import com.doyoonkim.knutice.R -import kotlin.random.Random - -class RunnerNotifier( - private val notificationManager: NotificationManager, - private val context: Context, -) : Notifier(notificationManager) { - override val channelId: String = context.getString(R.string.inapp_notification_channel_id) - override val channelName: String = context.getString(R.string.inapp_notificaiton_channel_name) - override val notificationId = Random(System.currentTimeMillis()).nextInt() - - override fun buildNotification(note: String): Notification { - return NotificationCompat.Builder(context, channelId) - .setContentTitle(context.getString(R.string.text_reminder_title)) - .setContentText(note) - .setSmallIcon(R.mipmap.ic_launcher) - .build() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/data/KnuticeRemoteSource.kt b/app/src/main/java/com/doyoonkim/knutice/data/KnuticeRemoteSource.kt deleted file mode 100644 index 7766063e..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/data/KnuticeRemoteSource.kt +++ /dev/null @@ -1,204 +0,0 @@ -package com.doyoonkim.knutice.data - -import android.util.Log -import com.doyoonkim.knutice.model.ApiDeviceTokenRequest -import com.doyoonkim.knutice.model.DeviceTokenRequest -import com.doyoonkim.knutice.model.NoticeCategory -import com.doyoonkim.knutice.model.NoticesPerPage -import com.doyoonkim.knutice.model.ApiPostResult -import com.doyoonkim.knutice.BuildConfig -import com.doyoonkim.knutice.model.ApiReportRequest -import com.doyoonkim.knutice.model.ApiTopicSubscriptionRequest -import com.doyoonkim.knutice.model.ManageTopicRequest -import com.doyoonkim.knutice.model.SingleNotice -import com.doyoonkim.knutice.model.ReportRequest -import com.doyoonkim.knutice.model.TopicSubscriptionStatusDTO -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import org.jsoup.Jsoup -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory -import retrofit2.http.Body -import retrofit2.http.GET -import retrofit2.http.Header -import retrofit2.http.Headers -import retrofit2.http.POST -import retrofit2.http.Path -import retrofit2.http.Query -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class KnuticeRemoteSource @Inject constructor() { - - private val knuticeService = Retrofit.Builder() - .baseUrl(BuildConfig.API_MIGRATED) - .addConverterFactory(GsonConverterFactory.create()) - .build() - - // TODO: Should relocate this variable. - private var validatedToken: String = "" - - suspend fun getTopThreeNotice(category: NoticeCategory, size: Int): NoticesPerPage { - return knuticeService.create(KnuticeService::class.java).run { - this.getTopThreeNotice(category, size) - } - } - - suspend fun getNoticeListPerPage(category: NoticeCategory, lastNttId: Int): NoticesPerPage { - Log.d("KnuticeRemoteSource", "Start retrofit service") - return knuticeService.create(KnuticeService::class.java).run { - if (lastNttId == 0) { - this.getFirstPageOfNotice(category) - } else { - this.getNoticeListPerPage(category, lastNttId) - } - } - } - - suspend fun getNoticeById(id: String): Result { - return runCatching { - knuticeService.create(KnuticeService::class.java).run { - this.getSingleNoticeById(id) - } - }.onFailure { throw it } - } - - suspend fun queryNoticesByKeyword(keyword: String): NoticesPerPage { - Log.d("KnuticeRemoteSource", "Start retrofit service (Querying Notices...)") - return knuticeService.create(KnuticeService::class.java).queryNoticeByKeyword(keyword) - } - - suspend fun getTopicSubscriptionStatus(): Result { - return runCatching { - knuticeService.create(KnuticeService::class.java).getTopicSubscriptionStatus(validatedToken) - }.onFailure { throw it } - } - - suspend fun getFullNoticeContent(url: String): Deferred = - CoroutineScope(Dispatchers.IO).async { - Jsoup.connect(url) - .get() - .getElementsByClass("bbs-view-content bbs-view-content-skin05") - .text() ?: "Unable to receive full notice content" - } - - suspend fun validateToken(token: String): Result { - Log.d("KnuticeRemoteSource", "Token Provided: $token") - try { - knuticeService.create(KnuticeService::class.java).validateToken( - ApiDeviceTokenRequest(body = DeviceTokenRequest(token)) - ).run { - if (this.result?.resultCode == 200) { - Log.d("KnuticeServer", "Token saved.") - validatedToken = token - return Result.success(true) - } else { - Log.d("KnuticeServer", "Failed to save token") - return Result.success(false) - } - } - } catch (e: Exception) { - Log.d("KnuticeServer", "Failed to validate token\nREASON:${e.message}") - return Result.failure(e) - } - } - - suspend fun submitUserReport(report: ReportRequest): Result { - Log.d("KnuticeRemoteSource", "ValidatedToken: $validatedToken") - try { - knuticeService.create(KnuticeService::class.java).submitUserReport( - ApiReportRequest(body = report.copy(fcmToken = validatedToken)) - ).run { - if (this.result?.resultCode == 200) { - Log.d("KnuticeServer", "User report has been submitted successfully.\n${this.body}") - return Result.success(true) - } else { - Log.d("KnuticeServer", "Failed to submit user report\n${this.body ?: false}") - return Result.success(false) - } - } - } catch (e: Exception) { - Log.d("KnuticeServer", "Failed to submit user report.\nREASON: ${e.message}") - return Result.failure(e) - } - } - - suspend fun submitTopicSubscriptionPreference(topic: NoticeCategory, status: Boolean): Result { - Log.d("KnuticeRemoteSource", "Update Topic Subscription Preference") - try { - knuticeService.create(KnuticeService::class.java).submitTopicSubscriptionPreference( - ApiTopicSubscriptionRequest( - body = ManageTopicRequest(validatedToken, topic.name, status) - ) - ).run { - if (this.result?.resultCode == 200) { - Log.d("KnuticeServer", "Topic preference has been updated.\n${this.body}") - return Result.success(true) - } else { - Log.d("KnuticeServer", "Failed to update topic preference.\n${this.body ?: false}") - return Result.success(false) - } - } - } catch (e: Exception) { - Log.d("KnuticeServer", "Failed to submit user report. \nREASON: ${e.message}") - return Result.failure(e) - } - } - -} - -interface KnuticeService { - - @GET("open-api/notice/list") - suspend fun getTopThreeNotice( - @Query("noticeName") category: NoticeCategory, - @Query("size") size: Int - ): NoticesPerPage - - @GET("open-api/notice/list") - suspend fun getNoticeListPerPage( - @Query("noticeName") category: NoticeCategory, - @Query("nttId") lastNttId: Int - ): NoticesPerPage - - @GET("open-api/notice/list") - suspend fun getFirstPageOfNotice( - @Query("noticeName") category: NoticeCategory - ): NoticesPerPage - - @GET("open-api/notice/{nttId}") - suspend fun getSingleNoticeById( - @Path("nttId") nttId: String - ): SingleNotice - - @GET("open-api/search") - suspend fun queryNoticeByKeyword( - @Query("keyword") keyword: String - ): NoticesPerPage - - @Headers("Content-Type: application/json") - @POST("open-api/fcm") - suspend fun validateToken( - @Body requestBody: ApiDeviceTokenRequest - ): ApiPostResult - - @Headers("Content-Type: application/json") - @POST("open-api/report") - suspend fun submitUserReport( - @Body requestBody: ApiReportRequest - ): ApiPostResult - - @Headers("Content-Type: application/json") - @POST("open-api/topic") - suspend fun submitTopicSubscriptionPreference( - @Body requestBody: ApiTopicSubscriptionRequest - ): ApiPostResult - - @GET("open-api/topic") - suspend fun getTopicSubscriptionStatus( - @Header("fcmToken") token: String - ): TopicSubscriptionStatusDTO -} diff --git a/app/src/main/java/com/doyoonkim/knutice/data/NoticeLocalRepository.kt b/app/src/main/java/com/doyoonkim/knutice/data/NoticeLocalRepository.kt deleted file mode 100644 index 7bd060ae..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/data/NoticeLocalRepository.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.doyoonkim.knutice.data - -import androidx.annotation.WorkerThread -import com.doyoonkim.knutice.data.local.KnuticeLocalSource -import com.doyoonkim.knutice.model.Bookmark -import com.doyoonkim.knutice.model.Notice -import com.doyoonkim.knutice.model.NoticeCategory -import com.doyoonkim.knutice.model.NoticeEntity -import com.doyoonkim.knutice.model.NoticesPerPage -import dagger.hilt.android.scopes.ActivityRetainedScoped -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn -import javax.inject.Inject - -/* -ActivityRetainedComponent lives across configuration changes, so it is created at the first onCreate - and last onDestroy, and when you mark your dependencies in ActivityRetainedComponent with - @ActivityRetainedScope its guarantees that your object will be a singleton and survive across - configuration changes - */ -// TODO: Consider change class name to KnuticeLocalRepository -@ActivityRetainedScoped -class NoticeLocalRepository @Inject constructor( - private val remoteSource: KnuticeRemoteSource, - private val localSource: KnuticeLocalSource -) { - // Local - fun createBookmark(bookmark: Bookmark): Result { - return localSource.createBookmark(bookmark) - } - - fun createBookmark(bookmark: Bookmark, targetNotice: Notice): Result { - return localSource.createBookmark(bookmark, targetNotice) - } - - fun updateBookmark(bookmark: Bookmark) { - localSource.updateBookmark(bookmark) - } - - fun deleteBookmark(bookmark: Bookmark) { - localSource.deleteBookmark(bookmark) - } - - fun deleteNoticeEntity(entity: NoticeEntity) { - localSource.deleteNoticeEntity(entity) - } - - fun getAllBookmarks(): Flow { - return flow { - runCatching { - localSource.getAllBookmarks().forEach { emit(it) } - }.onFailure { emit(Bookmark(-1)) } - } - } - - fun getNoticeByNttId(nttId: Int): Notice { - val noticeEntity = localSource.getNoticeByNttId(nttId) - return noticeEntity.toNotice() - } - - fun getBookmarkByNttID(nttId: Int): Result { - return runCatching { - localSource.getBookmarkByNttId(nttId) ?: Bookmark(-1) - }.onFailure { throw it } - } - - // Remote - @WorkerThread - fun getTopThreeNotice(category: NoticeCategory): Flow { - return flow { - delay(10L) - val response = remoteSource.getTopThreeNotice(category, 3) - - if (response.result?.resultCode == 200) emit(response) - else emit(NoticesPerPage()) - }.flowOn(Dispatchers.IO) - } - - fun getNoticesByCategoryPerPage(category: NoticeCategory, lastNttId: Int): Flow { - return flow { - val response = remoteSource.getNoticeListPerPage(category, lastNttId) - if (response.result?.resultCode == 200) { - emit(response) - } else { - NoticesPerPage() - } - }.flowOn(Dispatchers.IO) - } - - fun queryNoticesByKeyword(keyword: String): Flow { - return flow { - remoteSource.queryNoticesByKeyword(keyword).run { - if (this.result?.resultCode == 200) { - emit(this) - } else { - emit(NoticesPerPage()) - } - } - }.flowOn(Dispatchers.IO) - } - - fun getFullNoticeContent(url: String): Flow { - return flow { - emit(remoteSource.getFullNoticeContent(url).await()) - }.flowOn(Dispatchers.IO) - } - - fun postNoticeSubscriptionPreference(topic: NoticeCategory, status: Boolean): Flow> { - return flow { - emit(remoteSource.submitTopicSubscriptionPreference( - topic, status - )) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/data/local/KnuticeLocalSource.kt b/app/src/main/java/com/doyoonkim/knutice/data/local/KnuticeLocalSource.kt deleted file mode 100644 index 95ac77e0..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/data/local/KnuticeLocalSource.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.doyoonkim.knutice.data.local - -import android.content.Context -import com.doyoonkim.knutice.model.Bookmark -import com.doyoonkim.knutice.model.Notice -import com.doyoonkim.knutice.model.NoticeEntity -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject - - -class KnuticeLocalSource @Inject constructor( - @ApplicationContext private val context: Context -) { - // Local Database - private val localDatabase: LocalDatabase by lazy { - LocalDatabase.getInstance(context) - } - - // CURD - fun createBookmark(bookmark: Bookmark): Result { - return try { - localDatabase.getDao().createBookmark(bookmark) - Result.success(true) - } catch (e: Exception) { - Result.failure(e) - } - } - - fun createBookmark(bookmark: Bookmark, targetNotice: Notice): Result { - return runCatching { - // Insert Notice First. - localDatabase.getDao().createNoticeEntity(targetNotice.toNoticeEntity()) - // Insert Bookmark Entity - localDatabase.getDao().createBookmark(bookmark) - true - }.onFailure { throw it } - } - - fun updateBookmark(bookmark: Bookmark): Result { - return runCatching { - localDatabase.getDao().updateBookmark(bookmark) - true - }.onFailure { throw it } - } - - fun getAllBookmarks(): List { - return localDatabase.getDao().getAllBookmarks() - } - - fun deleteBookmark(bookmark: Bookmark): Result { - return runCatching { - localDatabase.getDao().deleteBookmark(bookmark) - true - }.onFailure { throw it } - } - - fun deleteNoticeEntity(entity: NoticeEntity): Result { - return runCatching { - localDatabase.getDao().deleteNoticeEntity(entity) - true - }.onFailure { throw it } - } - - fun getNoticeByNttId(nttId: Int): NoticeEntity { - return localDatabase.getDao().getNoticeByNttId(nttId) - } - - fun getBookmarkByNttId(nttId: Int): Bookmark? { - return localDatabase.getDao().getBookmarkByNttId(nttId) - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/data/local/LocalRoomDatabase.kt b/app/src/main/java/com/doyoonkim/knutice/data/local/LocalRoomDatabase.kt deleted file mode 100644 index fd42cf56..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/data/local/LocalRoomDatabase.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.doyoonkim.knutice.data.local - -import android.content.Context -import androidx.room.Database -import androidx.room.Room -import androidx.room.RoomDatabase -import com.doyoonkim.knutice.model.Bookmark -import com.doyoonkim.knutice.model.NoticeEntity - - -@Database(entities = [Bookmark::class, NoticeEntity::class], version = 1) -abstract class LocalDatabase : RoomDatabase() { - abstract fun getDao(): MainDatabaseDao - - companion object { - private var INSTANCE: LocalDatabase? = null - - fun getInstance(context: Context): LocalDatabase { - if (INSTANCE == null) { - synchronized(LocalDatabase::class) { - INSTANCE = Room.databaseBuilder( - context.applicationContext, - LocalDatabase::class.java, - "Main Local Database" - ).build() - } - } - return INSTANCE!! - } - } -} diff --git a/app/src/main/java/com/doyoonkim/knutice/di/AppComponent.kt b/app/src/main/java/com/doyoonkim/knutice/di/AppComponent.kt new file mode 100644 index 00000000..7d49f350 --- /dev/null +++ b/app/src/main/java/com/doyoonkim/knutice/di/AppComponent.kt @@ -0,0 +1,47 @@ +package com.doyoonkim.knutice.di + +import android.app.Application +import com.doyoonkim.bookmark.di.BookmarkModule +import com.doyoonkim.common.di.CommonModule +import com.doyoonkim.data.di.DataModule +import com.doyoonkim.domain.di.DomainModule +import com.doyoonkim.knutice.MainActivity +import com.doyoonkim.knutice.MainApplication +import com.doyoonkim.main.di.MainModule +import com.doyoonkim.notification.di.NotificationModule +import com.doyoonkim.notification.fcm.PushNotificationService +import dagger.BindsInstance +import dagger.Component +import javax.inject.Singleton + +@Singleton +@Component( + modules = [ + AppModule::class, + CommonModule::class, + DataModule::class, + DomainModule::class, + NotificationModule::class, + BookmarkModule::class, + MainModule::class, + ViewModelFactoryModule::class + ] +) +interface AppComponent { + + fun inject(app: MainApplication) + + fun inject(activity: MainActivity) + + fun inject(service: PushNotificationService) + + @Component.Factory + interface Factory { + // provide AppComponent + // @BindsInstance would bind provided 'Application' to DI graph. + // Therefore, Application instance would be bind alongside with AppComponent creation. + // --> This would provide 'Context' to DI graph as well because Application is a context. + fun create(@BindsInstance application: Application): AppComponent + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/di/AppModule.kt b/app/src/main/java/com/doyoonkim/knutice/di/AppModule.kt new file mode 100644 index 00000000..b9f6184b --- /dev/null +++ b/app/src/main/java/com/doyoonkim/knutice/di/AppModule.kt @@ -0,0 +1,19 @@ +package com.doyoonkim.knutice.di + +import android.app.Application +import android.content.Context +import com.doyoonkim.common.di.ApplicationContext +import dagger.Module +import dagger.Provides +import javax.inject.Singleton + +@Module +object AppModule { + + // Inject ApplicationContext + @Provides + @Singleton + @ApplicationContext + fun providesApplicationContext(app: Application): Context = app.applicationContext + +} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/di/DaggerViewModelFactory.kt b/app/src/main/java/com/doyoonkim/knutice/di/DaggerViewModelFactory.kt new file mode 100644 index 00000000..04f94ab2 --- /dev/null +++ b/app/src/main/java/com/doyoonkim/knutice/di/DaggerViewModelFactory.kt @@ -0,0 +1,20 @@ +package com.doyoonkim.knutice.di + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import javax.inject.Inject +import javax.inject.Provider + +class DaggerViewModelFactory @Inject constructor( + private val providers: @JvmSuppressWildcards Map, Provider> +) : ViewModelProvider.Factory { + // Function to lazily creates ViewModel instance + override fun create(modelClass: Class): T { + val provider = providers[modelClass] + ?: providers.entries.firstOrNull { modelClass.isAssignableFrom(it.key) }?.value + ?:throw IllegalArgumentException("Unknown ViewModel Class $modelClass") + + @Suppress("UNCHECKED_CAST") + return provider.get() as T + } +} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/di/ViewModelFactoryModule.kt b/app/src/main/java/com/doyoonkim/knutice/di/ViewModelFactoryModule.kt new file mode 100644 index 00000000..41c564bf --- /dev/null +++ b/app/src/main/java/com/doyoonkim/knutice/di/ViewModelFactoryModule.kt @@ -0,0 +1,13 @@ +package com.doyoonkim.knutice.di + +import androidx.lifecycle.ViewModelProvider +import dagger.Binds +import dagger.Module + +@Module +abstract class ViewModelFactoryModule { + + // @Binds annotation instructs Dagger to use the provided implementation. + @Binds + abstract fun bindViewModelFactory(factory: DaggerViewModelFactory): ViewModelProvider.Factory +} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/domain/CrawlFullContent.kt b/app/src/main/java/com/doyoonkim/knutice/domain/CrawlFullContent.kt deleted file mode 100644 index 8192a732..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/domain/CrawlFullContent.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.doyoonkim.knutice.domain - -import com.doyoonkim.knutice.model.DetailedContentState -import kotlinx.coroutines.flow.Flow - -interface CrawlFullContent { - - fun getFullContentFromSource(title: String, info: String, url: String): Flow - -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/domain/CrawlFullContentImpl.kt b/app/src/main/java/com/doyoonkim/knutice/domain/CrawlFullContentImpl.kt deleted file mode 100644 index ce97184e..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/domain/CrawlFullContentImpl.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.doyoonkim.knutice.domain - -import com.doyoonkim.knutice.data.NoticeLocalRepository -import com.doyoonkim.knutice.model.DetailedContentState -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import javax.inject.Inject - -class CrawlFullContentImpl @Inject constructor( - private val repository: NoticeLocalRepository -): CrawlFullContent { - override fun getFullContentFromSource( - title: String, - info: String, - url: String - ): Flow { - return repository.getFullNoticeContent(url) - .map { - DetailedContentState( - title = title, - info = info, - fullContent = it, - fullContentUrl = url - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/domain/FetchBookmarkFromDatabase.kt b/app/src/main/java/com/doyoonkim/knutice/domain/FetchBookmarkFromDatabase.kt deleted file mode 100644 index fe17d422..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/domain/FetchBookmarkFromDatabase.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.doyoonkim.knutice.domain - -import com.doyoonkim.knutice.data.NoticeLocalRepository -import com.doyoonkim.knutice.model.Bookmark -import com.doyoonkim.knutice.model.Notice -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import javax.inject.Inject - -class FetchBookmarkFromDatabase @Inject constructor( - private val repository: NoticeLocalRepository -) { - - fun getAllBookmarkWithNotices(): Flow> { - return repository.getAllBookmarks().map { - Pair(it, repository.getNoticeByNttId(it.nttId)) - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/domain/FetchListOfNotices.kt b/app/src/main/java/com/doyoonkim/knutice/domain/FetchListOfNotices.kt deleted file mode 100644 index 7f9aef2d..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/domain/FetchListOfNotices.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.doyoonkim.knutice.domain - -import com.doyoonkim.knutice.model.Notice -import com.doyoonkim.knutice.model.NoticeCategory -import com.doyoonkim.knutice.model.RawNoticeData -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf - -interface FetchListOfNotices { - - fun getNoticesPerPage(category: NoticeCategory, lastNttId: Int): Flow> - - fun getNoticesByKeyword(keyword: String): Flow> - - fun ArrayList.toNotice(): List { - return List(this.size) { index -> - Notice( - nttId = this[index].nttId ?: -1, - title = this[index].title ?: "Unknown", - url = this[index].contentUrl ?: "Unknown", - imageUrl = this[index].contentImage ?: "Unknown", - departName = this[index].departName ?: "Unknown", - timestamp = this[index].registeredAt ?: "Unknown" - ) - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/domain/FetchNoticesPerPageInCategory.kt b/app/src/main/java/com/doyoonkim/knutice/domain/FetchNoticesPerPageInCategory.kt deleted file mode 100644 index 82a43812..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/domain/FetchNoticesPerPageInCategory.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.doyoonkim.knutice.domain - -import com.doyoonkim.knutice.data.NoticeLocalRepository -import com.doyoonkim.knutice.model.Notice -import com.doyoonkim.knutice.model.NoticeCategory -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import javax.inject.Inject - -class FetchNoticesPerPageInCategory @Inject constructor( - private val repository: NoticeLocalRepository -) : FetchListOfNotices { - - override fun getNoticesPerPage(category: NoticeCategory, lastNttId: Int): Flow> { - return repository.getNoticesByCategoryPerPage(category, lastNttId).map { - it.body.toNotice() - } - } - - override fun getNoticesByKeyword(keyword: String): Flow> { - TODO("Does not required to be implemented.") - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/domain/FetchSingleNoticeImpl.kt b/app/src/main/java/com/doyoonkim/knutice/domain/FetchSingleNoticeImpl.kt deleted file mode 100644 index 7afc319f..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/domain/FetchSingleNoticeImpl.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.doyoonkim.knutice.domain - -import android.util.Log -import com.doyoonkim.knutice.data.KnuticeRemoteSource -import com.doyoonkim.knutice.model.Notice -import javax.inject.Inject - - -interface fetchSingleNotice { - suspend fun getSingleNoticeById(nttId: String): Notice -} - -class FetchSingleNoticeImpl @Inject constructor( - private val remoteSource: KnuticeRemoteSource -) : fetchSingleNotice { - - override suspend fun getSingleNoticeById(nttId: String): Notice { - remoteSource.getNoticeById(nttId).fold( - onSuccess = { - Log.d("FetchSingleNoticeImpl", it.notice?.toNotice().toString()) - return it.notice?.toNotice() ?: Notice() - }, - onFailure = { - Log.d("FetchSingleNoticeImpl", "Unable to receive notice") - return Notice() - } - ) - } - - suspend operator fun invoke(nttId: String): Notice { - return getSingleNoticeById(nttId) - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/domain/FetchTopThreeNotice.kt b/app/src/main/java/com/doyoonkim/knutice/domain/FetchTopThreeNotice.kt deleted file mode 100644 index b7ed09ca..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/domain/FetchTopThreeNotice.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.doyoonkim.knutice.domain - -import kotlinx.coroutines.flow.Flow - -interface FetchTopThreeNotice { - - fun fetchTopThreeGeneralNotice(): Flow - - fun fetchTopThreeAcademicNotice(): Flow - - fun fetchTopThreeScholarshipNotice(): Flow - - fun fetchTopThreeEventNotice(): Flow - - fun fetchAllTopThreeNotice(): Flow> - -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/domain/FetchTopThreeNoticeByCategory.kt b/app/src/main/java/com/doyoonkim/knutice/domain/FetchTopThreeNoticeByCategory.kt deleted file mode 100644 index 3ebb3c2f..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/domain/FetchTopThreeNoticeByCategory.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.doyoonkim.knutice.domain - -import android.util.Log -import com.doyoonkim.knutice.data.NoticeLocalRepository -import com.doyoonkim.knutice.model.Notice -import com.doyoonkim.knutice.model.NoticeCategory -import com.doyoonkim.knutice.model.RawNoticeData -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import java.util.Locale -import javax.inject.Inject - - -class FetchTopThreeNoticeByCategory @Inject constructor ( - private val repository: NoticeLocalRepository -): FetchTopThreeNotice { - - override fun fetchTopThreeGeneralNotice(): Flow { - return flowOf(TopThreeInCategory(false)) - } - - override fun fetchTopThreeAcademicNotice(): Flow { - return flowOf(TopThreeInCategory(false)) - } - - override fun fetchTopThreeScholarshipNotice(): Flow { - return flowOf(TopThreeInCategory(false)) - } - - override fun fetchTopThreeEventNotice(): Flow { - return flowOf(TopThreeInCategory(false)) - } - - override fun fetchAllTopThreeNotice(): Flow> { - TODO("Not yet implemented") - } - - fun getTopThreeNotices(category: NoticeCategory): Flow { - val translateNeeded: Boolean = Locale.getDefault() != Locale.KOREAN - return repository.getTopThreeNotice(category).map { - if (it.body.isNotEmpty()) { - val result = it.body.toNotice() - - TopThreeInCategory( - isSuccessful = true, - notice1 = result[0], - notice2 = result[1], - notice3 = result[2] - ).also { Log.d("TEST", it.toString()) } - } else { - TopThreeInCategory( - isSuccessful = false - ) - } - } - } - - private fun ArrayList.toNotice(): List { - return List(3) { index -> - Notice( - nttId = this[index].nttId ?: -1, - title = this[index].title ?: "Unknown", - url = this[index].contentUrl ?: "Unknown", - departName = this[index].departName ?: "Unknown", - timestamp = this[index].registeredAt ?: "Unknown", - imageUrl = this[index].contentImage ?: "Unknown" - ) - } - } - -} - -data class TopThreeInCategory( - val isSuccessful: Boolean, - val notice1: Notice? = null, - val notice2: Notice? = null, - val notice3: Notice? = null -) \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/domain/NoticeDummySource.kt b/app/src/main/java/com/doyoonkim/knutice/domain/NoticeDummySource.kt deleted file mode 100644 index 6479188d..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/domain/NoticeDummySource.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.doyoonkim.knutice.domain - -import android.util.Log -import com.doyoonkim.knutice.model.RawNoticeData -import com.doyoonkim.knutice.model.Result -import com.doyoonkim.knutice.model.TopThreeNotices -import kotlinx.coroutines.delay - -class NoticeDummySource { - companion object { - /** - * TEST ONLY (Should be removed later) - */ - suspend fun getTopThreeNoticeDummy(): TopThreeNotices { - Log.d("KnuticeRemoteSource", "Dummy Data Created") - delay(1000) - return TopThreeNotices( - result = Result( - resultCode = 200, - resultMessage = "Dummy Data", - resultDescription = "DummyData Created" - ), - body = TopThreeNotices.Body( - latestThreeGeneralNews = arrayListOf( - RawNoticeData( - 1, "General 0", "https:/www.ut.ac.kr/", "Dept", "2024-00-00" - ), - RawNoticeData( - 2, "General 1", "https:/www.ut.ac.kr/", "Dept", "2024-00-00" - ), - RawNoticeData( - 3, "General 2", "https:/www.ut.ac.kr/", "Dept", "2024-00-00" - ) - ), - latestThreeAcademicNews = arrayListOf( - RawNoticeData( - 1, "Academic 0", "https:/www.ut.ac.kr/", "Dept", "2024-00-00" - ), - RawNoticeData( - 2, "Academic 1", "https:/www.ut.ac.kr/", "Dept", "2024-00-00" - ), - RawNoticeData( - 3, "Academic 2", "https:/www.ut.ac.kr/", "Dept", "2024-00-00" - ) - ), - latestThreeScholarshipNews = arrayListOf( - RawNoticeData( - 1, "Scholarship 0", "https:/www.ut.ac.kr/", "Dept", "2024-00-00" - ), - RawNoticeData( - 2, "Scholarship 1", "https:/www.ut.ac.kr/", "Dept", "2024-00-00" - ), - RawNoticeData( - 3, "Scholarship 2", "https:/www.ut.ac.kr/", "Dept", "2024-00-00" - ) - ), - latestThreeEventNews = arrayListOf( - RawNoticeData( - 1, "Event 0", "https:/www.ut.ac.kr/", "Dept", "2024-00-00" - ), - RawNoticeData( - 2, "Event 1", "https:/www.ut.ac.kr/", "Dept", "2024-00-00" - ), - RawNoticeData( - 3, "Event 2", "https:/www.ut.ac.kr/", "Dept", "2024-00-00" - ) - ) - ) - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/domain/QueryNoticesUsingKeyword.kt b/app/src/main/java/com/doyoonkim/knutice/domain/QueryNoticesUsingKeyword.kt deleted file mode 100644 index ec242988..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/domain/QueryNoticesUsingKeyword.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.doyoonkim.knutice.domain - -import com.doyoonkim.knutice.data.NoticeLocalRepository -import com.doyoonkim.knutice.model.Notice -import com.doyoonkim.knutice.model.NoticeCategory -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import javax.inject.Inject - -class QueryNoticesUsingKeyword @Inject constructor( - private val repository: NoticeLocalRepository -): FetchListOfNotices { - - override fun getNoticesPerPage(category: NoticeCategory, lastNttId: Int): Flow> { - TODO("Does not required to be implemented.") - } - - override fun getNoticesByKeyword(keyword: String): Flow> { - return repository.queryNoticesByKeyword(keyword).map { - it.body.toNotice() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/fcm/PushNotificationHandler.kt b/app/src/main/java/com/doyoonkim/knutice/fcm/PushNotificationHandler.kt deleted file mode 100644 index 6a6e3753..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/fcm/PushNotificationHandler.kt +++ /dev/null @@ -1,120 +0,0 @@ -package com.doyoonkim.knutice.fcm - -import android.Manifest -import android.app.Notification -import android.app.PendingIntent -import android.content.Intent -import android.content.pm.PackageManager -import android.graphics.drawable.Icon -import android.os.Bundle -import android.util.Log -import androidx.core.app.ActivityCompat -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import com.doyoonkim.knutice.data.KnuticeRemoteSource -import com.doyoonkim.knutice.R -import com.doyoonkim.knutice.presentation.MainActivity -import com.google.android.gms.tasks.OnCompleteListener -import com.google.firebase.messaging.FirebaseMessaging -import com.google.firebase.messaging.FirebaseMessagingService -import com.google.firebase.messaging.RemoteMessage -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import javax.inject.Inject -import kotlin.random.Random - -class PushNotificationHandler @Inject constructor() : FirebaseMessagingService() { - @Inject lateinit var remoteSource: KnuticeRemoteSource - private val TAG = "PushNotificationHandler" - - override fun onNewToken(token: String) { - super.onNewToken(token) - - // POST request to send FCM Token to the Server. - Log.d(TAG, "Received Token: ${token.toString()}") - } - - override fun onMessageReceived(message: RemoteMessage) { - super.onMessageReceived(message) - - // When the app is in background or killed, Data Payload would be delivered once the user - // clicks the system tray. - Log.d(TAG, "Message data payload: ${message.notification}") - - if (message.data.isNotEmpty()) { - Log.d(TAG, "Message Data Payload: ${message.data}") // message.data: Map - - // Apply "Do not disturb" option. (Temporarily save the message and deliver after the core time is end. - // Use Local Database (Room?) - message.notification?.let { - Log.d(TAG, "Body: ${it.body}") - message.toPushNotification() - } - } - } - - private fun RemoteMessage.toPushNotification() { - // Create Pending Intent (For access push notification while the app is in foreground) - val intent = Intent(applicationContext, MainActivity::class.java).apply { - this@toPushNotification.data.forEach { - putExtra(it.key, it.value) - } - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - } - val pendingIntent = PendingIntent.getActivity(applicationContext, 0, intent, PendingIntent.FLAG_IMMUTABLE) - - val notificationId = Random(System.currentTimeMillis().toInt()).nextInt() - // Utilize channel already created by FCM as default - val notificationBuilder = NotificationCompat.Builder( - applicationContext, getString(R.string.inapp_notification_channel_id) - ).apply { - setSmallIcon(R.mipmap.ic_launcher) - setLargeIcon(Icon.createWithResource(applicationContext, R.mipmap.ic_launcher)) - setContentTitle(getString(R.string.new_notice)) - setContentText(this@toPushNotification.notification?.body ?: "No message body.") - setContentIntent(pendingIntent) - setPriority(NotificationCompat.PRIORITY_DEFAULT) - setAutoCancel(true) - } - - - with(NotificationManagerCompat.from(applicationContext)) { - if (ActivityCompat.checkSelfPermission( - applicationContext, - Manifest.permission.POST_NOTIFICATIONS - ) != PackageManager.PERMISSION_GRANTED - ) { - Log.d("NotificationHandler", "Permission Denied") - // TODO: Consider calling - // ActivityCompat#requestPermissions - // here to request the missing permissions, and then overriding - // public void onRequestPermissionsResult(int requestCode, String[] permissions, - // int[] grantResults) - // to handle the case where the user grants the permission. See the documentation - // for ActivityCompat#requestPermissions for more details. - return - } - notify(notificationId, notificationBuilder.build()) - } - } - - fun requestCurrentToken() { - FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task -> - if (!task.isSuccessful) { - Log.d(TAG, "Incomplete task: ${task.exception}") - return@OnCompleteListener - } - - // Get new FCM registration token - val registrationToken = task.result - Log.d(TAG, "Received Token: $registrationToken") - - // POST request to upload current token to the web server. - CoroutineScope(Dispatchers.IO).launch { - remoteSource.validateToken(registrationToken) - } - }) - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/model/DataWrappers.kt b/app/src/main/java/com/doyoonkim/knutice/model/DataWrappers.kt deleted file mode 100644 index 9a6a9575..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/model/DataWrappers.kt +++ /dev/null @@ -1,208 +0,0 @@ -package com.doyoonkim.knutice.model - -import com.google.gson.annotations.SerializedName -import kotlinx.serialization.Serializable - - -data class Result( - @SerializedName("resultCode") var resultCode: Int? = null, - @SerializedName("resultMessage") var resultMessage: String? = null, - @SerializedName("resultDescription") var resultDescription: String? = null -) - -data class RawNoticeData( - @SerializedName("nttId") var nttId: Int? = null, - @SerializedName("title") var title: String? = null, - @SerializedName("contentUrl") var contentUrl: String? = null, - @SerializedName("contentImage") var contentImage: String? = null, - @SerializedName("departmentName") var departName: String? = null, - @SerializedName("registeredAt") var registeredAt: String? = null, - @SerializedName("noticeName") var noticeCategory: String? = null -) - -// POJO for receiving raw data from the server. -data class TopThreeNotices( - @SerializedName("result" ) var result : Result? = Result(), - @SerializedName("body" ) var body : Body? = Body() -) { - data class Body( - @SerializedName("latestThreeGeneralNews") var latestThreeGeneralNews: ArrayList = arrayListOf(), - @SerializedName("latestThreeScholarshipNews") var latestThreeScholarshipNews: ArrayList = arrayListOf(), - @SerializedName("latestThreeEventNews") var latestThreeEventNews: ArrayList = arrayListOf(), - @SerializedName("latestThreeAcademicNews") var latestThreeAcademicNews: ArrayList = arrayListOf() - ) -} - -data class TopicSubscriptionStatusDTO( - @SerializedName("result") var result: Result? = Result(), - @SerializedName("body") var body: Body? = Body() -) { - data class Body( - @SerializedName("generalNewsTopic") var generalNewsTopic: Boolean = false, - @SerializedName("scholarshipNewsTopic") var scholarshipNewsTopic: Boolean = false, - @SerializedName("eventNewsTopic") var eventNewsTopic: Boolean = false, - @SerializedName("academicNewsTopic") var academicNewsTopic: Boolean = false, - @SerializedName("employmentNewsTopic") var employmentNewsTopic: Boolean = false - ) -} - -data class SingleNotice( - @SerializedName("result") var result: Result? = Result(), - @SerializedName("body") var notice: NoticeDTO? -) { - data class NoticeDTO( - @SerializedName("nttId") var nttId: Int, - @SerializedName("title") var title: String, - @SerializedName("contentUrl") var contentUrl: String, - @SerializedName("contentImage") var contentImage: String?, - @SerializedName("departmentName") var departmentName: String, - @SerializedName("registeredAt") var registeredAt: String, - @SerializedName("noticeName") var noticeName: String - ) { - fun toNotice(): Notice { - return Notice( - nttId = nttId, - title = title, - url = contentUrl, - imageUrl = contentImage ?: "", - departName = departmentName, - timestamp = registeredAt, - // noticeCategory = noticeName - ) - } - } -} - -data class NoticesPerPage( - @SerializedName("result") var result: Result? = Result(), - @SerializedName("body") var body: ArrayList = arrayListOf() -) - -data class ApiPostResult( - @SerializedName("result") var result: Result? = Result(), - @SerializedName("body") var body: Boolean? = null -) - -data class ApiDeviceTokenRequest( - val result: Result = Result(), - val body: DeviceTokenRequest -) - -data class DeviceTokenRequest( - val fcmToken: String -) - -data class ApiReportRequest( - val result: Result = Result(), - val body: ReportRequest -) - -data class ReportRequest( - val fcmToken: String = "", - val content: String = "", - val clientType: String = "APP", - val deviceName: String = "", - val version: String = "" -) - -data class ApiTopicSubscriptionRequest( - val result: Result = Result(), - val body: ManageTopicRequest = ManageTopicRequest() -) - -data class ManageTopicRequest( - val fcmToken: String = "", - val noticeName: String = "", - val isSubscribed: Boolean = false -) - -// Data class to be applied to uiState. -// Universal -@Serializable -data class Notice( - val nttId: Int = -1, - val title: String = "Unknown", - val url: String = "Unknown", - val imageUrl: String = "", - val departName: String = "Unknown", - val timestamp: String = "Unknown" -) { - fun toFullContent(): FullContent { - return FullContent( - title, - "[$departName] $timestamp", - url, - imageUrl, - nttId.toString() - ) - } - - fun toNoticeEntity(): NoticeEntity { - return NoticeEntity( - noticeEntityId = 0, - nttId = nttId, - title = title, - url = url, - imageUrl = imageUrl, - departName = departName, - timestamp = timestamp - ) - } -} - -// DetailedNoticeContent -data class DetailedContentState( - val requestedNotice: Notice = Notice(), - val url: String = "", - val title: String = "", - val info: String = "", - val fullContent: String = "", - val fullContentUrl: String = "", - val imageUrl: String = "", - val loadingStatue: Float = 0.0f -) - -// CustomerService -data class CustomerServiceReportState( - val userReport: String = "", - val reachedMaxCharacters: Boolean = false, - val exceedMinCharacters: Boolean = false, - val isSubmissionFailed: Boolean = false, - val isSubmissionCompleted: Boolean = false -) - -// Search -data class SearchNoticeState( - val searchKeyword: String = "", - val isQuerying: Boolean = false, - val queryResult: List = emptyList() -) - -// NotificationPreference -data class NotificationPreferenceStatus( - val isMainNotificationPermissionGranted: Boolean = false, - //TODO: Consider change data type to MAP - val isEachChannelAllowed: List = listOf(false, false, false, false), - val isSyncCompleted: Boolean = false, - val isError: Boolean = false -) - -// BookmarkComposable -data class BookmarkComposableState( - val bookmarks: List> = emptyList(), - val isRefreshing: Boolean = false, - val isRefreshRequested: Boolean = false -) - -// EditBookmark -data class EditBookmarkState( - val bookmarkId: Int = 0, - val targetNotice: Notice = Notice(), - val isReminderRequested: Boolean = false, - val timeForRemind: Long = 0, // Should be replaced with an appropriate data type later. - val bookmarkNote: String = "", - val requireCreation: Boolean = true, - val bookmarkInstance: Bookmark? = null, - val datePickerVisible: Boolean = false -) - diff --git a/app/src/main/java/com/doyoonkim/knutice/model/RoomEntities.kt b/app/src/main/java/com/doyoonkim/knutice/model/RoomEntities.kt deleted file mode 100644 index 1c18f1ea..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/model/RoomEntities.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.doyoonkim.knutice.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey - -@Entity -data class NoticeEntity( - @PrimaryKey(autoGenerate = true) val noticeEntityId: Int, - @ColumnInfo("ntt_id") val nttId: Int = -1, - @ColumnInfo("notice_title") val title: String, - @ColumnInfo("notice_url") val url: String, - @ColumnInfo("notice_image") val imageUrl: String, - @ColumnInfo("info_dept") val departName: String, - @ColumnInfo("info_timestamp") val timestamp: String -) { - fun toNotice(): Notice { - return Notice(nttId, title, url, imageUrl, departName, timestamp) - } -} - -@Entity -data class Bookmark( - @PrimaryKey(autoGenerate = true) val bookmarkId: Int, - @ColumnInfo("isScheduled") val isScheduled: Boolean = false, - @ColumnInfo("remind_schedule") val reminderSchedule: Long = 0, - @ColumnInfo("bookmark_note") val note: String = "", - @ColumnInfo("target_ntt_id") val nttId: Int = -1 -) - -/* -data class Notice( - val nttId: Int = -1, - val title: String = "Unknown", - val url: String = "Unknown", - val imageUrl: String = "", - val departName: String = "Unknown", - val timestamp: String = "Unknown" -) - */ \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/model/Types.kt b/app/src/main/java/com/doyoonkim/knutice/model/Types.kt deleted file mode 100644 index bb2f99e7..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/model/Types.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.doyoonkim.knutice.model - -import kotlinx.serialization.Serializable - -enum class NoticeCategory { GENERAL_NEWS, ACADEMIC_NEWS, SCHOLARSHIP_NEWS, EVENT_NEWS, Unspecified } - -enum class Destination { MAIN, MORE_GENERAL, MORE_ACADEMIC, MORE_SCHOLARSHIP, MORE_EVENT, DETAILED, - SETTINGS, OSS, CS, SEARCH, NOTIFICATION, BOOKMARKS, EDIT_BOOKMARK, Unspecified } - -// Navigation Destinations -@Serializable -data class NavDestination( - val arrived: Destination = Destination.Unspecified -) - - -// TODO: Change structure of FullContent (FullContent(notice: Notice, url: String)) -@Serializable -data class FullContent( - val title: String? = null, - val info: String? = null, - val url: String, - val imgUrl: String, - val nttId: String, -) \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/navigation/MainNavigator.kt b/app/src/main/java/com/doyoonkim/knutice/navigation/MainNavigator.kt deleted file mode 100644 index dfe76ae3..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/navigation/MainNavigator.kt +++ /dev/null @@ -1,180 +0,0 @@ -package com.doyoonkim.knutice.navigation - -import android.util.Log -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.toRoute -import com.doyoonkim.knutice.model.Destination -import com.doyoonkim.knutice.model.FullContent -import com.doyoonkim.knutice.model.NavDestination -import com.doyoonkim.knutice.model.Notice -import com.doyoonkim.knutice.presentation.BookmarkComposable -import com.doyoonkim.knutice.presentation.CategorizedNotification -import com.doyoonkim.knutice.presentation.CustomerService -import com.doyoonkim.knutice.presentation.DetailedNoticeContent -import com.doyoonkim.knutice.presentation.EditBookmark -import com.doyoonkim.knutice.presentation.MoreCategorizedNotification -import com.doyoonkim.knutice.presentation.NotificationPreferences -import com.doyoonkim.knutice.presentation.OpenSourceLicenseNotice -import com.doyoonkim.knutice.presentation.UserPreference -import com.doyoonkim.knutice.presentation.SearchNotice -import com.doyoonkim.knutice.viewModel.MainServiceViewModel - -@Composable -fun MainNavigator( - modifier: Modifier = Modifier, - viewModel: MainServiceViewModel = hiltViewModel(), - navController: NavHostController, - onExitAction: (Boolean) -> Unit -) { - // Navigation - NavHost( - modifier = modifier, - navController = navController, - startDestination = NavDestination(arrived = Destination.MAIN), - ) { - composable { backStackEntry -> - val destination = backStackEntry.toRoute() - viewModel.updateState( - updatedCurrentLocation = destination.arrived - ) - - if (destination.arrived == Destination.CS - || destination.arrived == Destination.OSS) { - viewModel.updateState( - updatedBottomNavBarVisibility = false - ) - } else { - viewModel.updateState( - updatedBottomNavBarVisibility = true - ) - } - - when (destination.arrived) { - Destination.MAIN -> { - CategorizedNotification( - onGoBackAction = { navController.popBackStack().also { - if (!it) onExitAction(it) - } }, - onMoreNoticeRequested = { navController.navigate(NavDestination(arrived = it)) }, - onFullContentRequested = { - viewModel.updateState(updatedTempReservedNoticeForBookmark = it) - navController.navigate(it.toFullContent()) - } - ) - } - Destination.SETTINGS -> UserPreference( - Modifier.padding(top = 20.dp, start = 10.dp, end = 10.dp).fillMaxSize(), - onCustomerServiceClicked = { navController.navigate(NavDestination(it))}, - onNotificationPreferenceClicked = { navController.navigate(NavDestination(it)) }, - onOssClicked = { navController.navigate(NavDestination(it)) }, - onBackPressed = { navController.popBackStack().also { - if (!it) navController.run { - navController.navigate(NavDestination(Destination.MAIN)) - } - } } - ) - Destination.OSS -> OpenSourceLicenseNotice() - Destination.CS -> { CustomerService(Modifier.padding(15.dp)) } - Destination.SEARCH -> SearchNotice( - onBackClicked = { navController.popBackStack().also { - if (!it) navController.run { - navController.navigate(NavDestination(Destination.MAIN)) - } - } }, - onNoticeClicked = { - viewModel.updateState(updatedTempReservedNoticeForBookmark = it) - navController.navigate(it.toFullContent()) - } - ) - Destination.NOTIFICATION -> NotificationPreferences( - onBackClicked = { navController.popBackStack() }, - onMainNotificationSwitchToggled = { } - ) - Destination.BOOKMARKS -> BookmarkComposable( - modifier =Modifier.fillMaxSize(), - onEachItemClicked = { navController.navigate(it) }, - onBackPressed = { navController.popBackStack().also { - if (!it) navController.run { - navController.navigate(NavDestination(Destination.MAIN)) - } - } } - ) - else -> MoreCategorizedNotification( - backButtonHandler = { navController.popBackStack().also { - if (!it) navController.run { - navController.navigate(NavDestination(Destination.MAIN)) - } - } }, - onNoticeSelected = { - viewModel.updateState(updatedTempReservedNoticeForBookmark = it) - navController.navigate(it.toFullContent()) - } - ) - } - } - - composable { backStackEntry -> - val requestedNotice = backStackEntry.toRoute() - val scaffoldTitle = requestedNotice.title - - viewModel.run { - if (uiState.value.currentLocation != Destination.DETAILED) { - updateState( - updatedCurrentLocation = Destination.DETAILED, - updatedCurrentScaffoldTitle = scaffoldTitle ?: "Full Content", - updatedBottomNavBarVisibility = true - ) - - // Need to find the reason for multiple request caused by multiple recomposition - if (requestedNotice.nttId != uiState.value.tempReserveNoticeForBookmark.nttId.toString()) { - getReservedNotice(requestedNotice.nttId) - } - } - } - DetailedNoticeContent( - onBackPressed = { navController.popBackStack().also { - if (!it) navController.run { navController.navigate(NavDestination(Destination.MAIN)) } - } } - ) - Spacer(Modifier.height(20.dp)) - } - - composable { - viewModel.updateState( - updatedCurrentLocation = Destination.EDIT_BOOKMARK, - updatedBottomNavBarVisibility = false - ) - EditBookmark( - Modifier.padding(10.dp), - onDetailedNoticeRequested = { - viewModel.updateState( - updatedFabVisibility = false, - ) - navController.navigate(it.toFullContent()) - } - ) { bookmark -> - Log.d("MainNavigator", "Bookmark instance received: ${bookmark ?: "null"}") - // onSavedClicked - if (bookmark != null) { - Log.d("MainNavigator", "Bookmark instance received.") - viewModel.updateState( - updatedCurrentTargetBookmark = bookmark, - updatedScheduleTriggered = true - ) - } - navController.navigate(NavDestination(Destination.BOOKMARKS)) - } - } - - } -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/BookmarkComposable.kt b/app/src/main/java/com/doyoonkim/knutice/presentation/BookmarkComposable.kt deleted file mode 100644 index 15760329..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/presentation/BookmarkComposable.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.doyoonkim.knutice.presentation - -import android.util.Log -import androidx.activity.compose.BackHandler -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel -import com.doyoonkim.knutice.R -import com.doyoonkim.knutice.model.Notice -import com.doyoonkim.knutice.presentation.component.NotificationPreviewCardMarked -import com.doyoonkim.knutice.ui.theme.containerBackgroundSolid -import com.doyoonkim.knutice.ui.theme.displayBackground -import com.doyoonkim.knutice.ui.theme.subTitle -import com.doyoonkim.knutice.viewModel.BookmarkViewModel - -@Composable -fun BookmarkComposable( - modifier: Modifier = Modifier, - viewModel: BookmarkViewModel = hiltViewModel(), - onEachItemClicked: (Notice) -> Unit = { }, - onBackPressed: () -> Unit = { } -) { - val uiState by viewModel.uiState.collectAsState() - - BackHandler { - onBackPressed() - } - -// LaunchedEffect(uiState.bookmarks) { -// viewModel.getAllBookmarks() -// } - - Box( - modifier = modifier.background(MaterialTheme.colorScheme.containerBackgroundSolid) - .systemBarsPadding() - ) { - if (uiState.bookmarks.isEmpty()) { - Text( - text = stringResource(R.string.text_no_bookmark), - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.subTitle, - modifier = Modifier.align(Alignment.Center) - ) - } else { - LazyColumn( - modifier = Modifier.wrapContentHeight() - .fillMaxWidth() - .padding(top = 12.dp) - .background(Color.Transparent), - verticalArrangement = Arrangement.spacedBy(12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - items( - items = uiState.bookmarks, - key = { it.second.nttId } - ) { - // Being called 3 times - Log.d("BookmarkComposable", "Element: $it") - NotificationPreviewCardMarked( - noticeTitle = it.second.title, - noticeSubtitle = "[${it.second.departName}] ${it.second.timestamp}", - onItemClicked = { onEachItemClicked(it.second) } - ) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/MainActivity.kt b/app/src/main/java/com/doyoonkim/knutice/presentation/MainActivity.kt deleted file mode 100644 index cae99d4b..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/presentation/MainActivity.kt +++ /dev/null @@ -1,173 +0,0 @@ -package com.doyoonkim.knutice.presentation - -import android.Manifest -import android.content.Intent -import android.os.Bundle -import android.provider.Settings -import android.util.Log -import androidx.activity.ComponentActivity -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBars -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.LaunchedEffect -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.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.core.view.WindowCompat -import androidx.navigation.compose.rememberNavController -import com.doyoonkim.knutice.R -import com.doyoonkim.knutice.alarm.NotificationAlarmScheduler -import com.doyoonkim.knutice.model.FullContent -import com.doyoonkim.knutice.navigation.MainNavigator -import com.doyoonkim.knutice.presentation.component.PermissionRationaleComposable -import com.doyoonkim.knutice.ui.theme.KNUTICETheme -import com.doyoonkim.knutice.ui.theme.containerBackgroundSolid -import dagger.hilt.android.AndroidEntryPoint -import java.util.Locale - -@AndroidEntryPoint -class MainActivity : ComponentActivity() { - - private val notificationAlarmScheduler by lazy { - NotificationAlarmScheduler(this) - } - - private val activity = this - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) -// applicationContext.deleteDatabase("Main Local Database") - WindowCompat.setDecorFitsSystemWindows(window, false) - enableEdgeToEdge() - setContent { - KNUTICETheme { - val context = LocalContext.current - val navController = rememberNavController() - var showPermissionRationale by remember { mutableStateOf(false) } - - // Permission Launcher - val requestPermissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestMultiplePermissions() - ) { permissions -> - permissions.entries.forEach { - Log.d("MainServiceScreen", "${it.key}, ${it.value}") - if (it.key == Manifest.permission.SCHEDULE_EXACT_ALARM - && !notificationAlarmScheduler.canScheduleExactAlarms()) { - showPermissionRationale = true - } - } - } - - LaunchedEffect(Unit) { - // Permission check - requestPermissionLauncher.launch( - arrayOf( - Manifest.permission.POST_NOTIFICATIONS, - Manifest.permission.SCHEDULE_EXACT_ALARM - ) - ) - - // Handling Push Notification Click Action - // Check there's a extra or not. - val requestedIntent = this@MainActivity.intent - if (requestedIntent.getStringExtra("nttId") != null) { - // Navigate to Detailed Notice - - // When the app is in background or killed, Data Payload would be delivered once the user - // clicks the system tray. (Data Payload will be delivered as Intent) - FullContent( - requestedIntent.getStringExtra("contentTitle"), - requestedIntent.getStringExtra("noticeName"), - requestedIntent.getStringExtra("contentUrl") ?: "", - requestedIntent.getStringExtra("contentImage") ?: "", - requestedIntent.getStringExtra("nttId").toString() - ).run { - navController.navigate(this) - } - } - } - - MainServiceScreen( - navController = navController, - onScheduleAlarmTriggered = { - alarmTarget -> - if (!alarmTarget.isScheduled) notificationAlarmScheduler.cancel(alarmTarget) - else notificationAlarmScheduler.schedule(alarmTarget) - } - ) { innerPadding -> - MainNavigator( - navController = navController, modifier = Modifier - .consumeWindowInsets(WindowInsets.systemBars) - .padding( - PaddingValues( - top = innerPadding.calculateTopPadding(), - bottom = innerPadding.calculateBottomPadding() - ) - ) - .background(MaterialTheme.colorScheme.containerBackgroundSolid) - ) { - // onExitAction - activity.finish() - } - } - - AnimatedVisibility( - visible = showPermissionRationale, - enter = scaleIn(), - exit = scaleOut() - ) { - Box( - modifier = Modifier.fillMaxSize() - .clickable { showPermissionRationale = false } - ) { - PermissionRationaleComposable( - modifier = Modifier.align(Alignment.Center).padding(start = 20.dp, end = 20.dp), - permissionName = stringResource(R.string.title_alarm_and_reminder), - rationaleTitle = stringResource(R.string.text_rationale_title), - description = stringResource(R.string.text_rationale_description) - ) { - val settingIntent = Intent( - Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM - ).apply { - this.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - this.putExtra( - "android.provider.extra.APP_PACKAGE", - context.packageName - ) - } - context.startActivity(settingIntent) - showPermissionRationale = false - } - } - } - } - } - } - - override fun onDestroy() { - super.onDestroy() - - viewModelStore.clear() - this.externalCacheDir?.delete() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/MainServiceScreen.kt b/app/src/main/java/com/doyoonkim/knutice/presentation/MainServiceScreen.kt deleted file mode 100644 index 75444dce..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/presentation/MainServiceScreen.kt +++ /dev/null @@ -1,320 +0,0 @@ -package com.doyoonkim.knutice.presentation - -import android.util.Log -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.material.BottomNavigationItem -import androidx.compose.material.Icon -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material3.BottomAppBar -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.FloatingActionButtonDefaults -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavController -import androidx.navigation.NavHostController -import androidx.navigation.compose.rememberNavController -import com.doyoonkim.knutice.R -import com.doyoonkim.knutice.model.Bookmark -import com.doyoonkim.knutice.model.Destination -import com.doyoonkim.knutice.model.NavDestination -import com.doyoonkim.knutice.navigation.MainNavigator -import com.doyoonkim.knutice.ui.theme.bottomNavContainer -import com.doyoonkim.knutice.ui.theme.containerBackground -import com.doyoonkim.knutice.ui.theme.containerBackgroundSolid -import com.doyoonkim.knutice.ui.theme.displayBackground -import com.doyoonkim.knutice.ui.theme.subTitle -import com.doyoonkim.knutice.ui.theme.textPurple -import com.doyoonkim.knutice.ui.theme.title -import com.doyoonkim.knutice.viewModel.MainServiceViewModel -import kotlinx.coroutines.async - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MainServiceScreen( - viewModel: MainServiceViewModel = hiltViewModel(), - navController: NavHostController, - onScheduleAlarmTriggered: (Bookmark) -> Unit, // Should be refactored later - content: @Composable (PaddingValues) -> Unit -) { - val mainAppState by viewModel.uiState.collectAsState() - -// //TODO Live translation feature (TBD) -// var showLanguageDownloadRationale by remember { mutableStateOf(false) } -// LaunchedEffect(Unit) { -// if (Locale.getDefault() != Locale.KOREAN) { -// showLanguageDownloadRationale = true -// } -// } - - LaunchedEffect(mainAppState.scheduleTriggered) { - Log.d("MainServiceScreen", "Triggered") - if (!mainAppState.scheduleTriggered || mainAppState.currentTargetBookmark.bookmarkId == -1) { - return@LaunchedEffect - } - val scheduleResult = async { - kotlin.runCatching { - onScheduleAlarmTriggered(mainAppState.currentTargetBookmark) - } - } - scheduleResult.await().fold( - onSuccess = { - viewModel.updateState( - updatedCurrentTargetBookmark = Bookmark(-1), - updatedScheduleTriggered = false - ) - }, - onFailure = { - Log.d("MainServiceScreen", "Unable to schedule alarm.\nREASON:${it}") - viewModel.updateState( - updatedScheduleTriggered = false - ) - } - ) - } - - Scaffold( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.containerBackgroundSolid), - topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.wrapContentSize(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - if (mainAppState.currentLocation != Destination.MAIN - && mainAppState.currentLocation != Destination.BOOKMARKS - ) { - IconButton( - onClick = { - navController.popBackStack().also { - viewModel.updateState(updatedFabVisibility = true) - } - } - ) { - Image( - painter = if (mainAppState.currentLocation == Destination.CS) { - painterResource(R.drawable.baseline_close_24) - } else { - painterResource(R.drawable.baseline_arrow_back_ios_new_24) - }, - contentDescription = "back", - modifier = Modifier.wrapContentSize(), - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.title) - ) - } - } - Text( - text = when (mainAppState.currentLocation) { - Destination.MAIN -> stringResource(R.string.app_name) - Destination.MORE_GENERAL -> stringResource(R.string.general_news) - Destination.MORE_ACADEMIC -> stringResource(R.string.academic_news) - Destination.MORE_SCHOLARSHIP -> stringResource(R.string.scholarship_news) - Destination.MORE_EVENT -> stringResource(R.string.event_news) - Destination.SETTINGS -> stringResource(R.string.title_preference) - Destination.OSS -> stringResource(R.string.oss_notice) - Destination.CS -> stringResource(R.string.title_customer_service) - Destination.SEARCH -> stringResource(R.string.title_search) - Destination.NOTIFICATION -> stringResource(R.string.title_notification_pref) - Destination.BOOKMARKS -> stringResource(R.string.app_name) - Destination.EDIT_BOOKMARK -> stringResource(R.string.title_edit_bookmark) - Destination.DETAILED -> mainAppState.currentScaffoldTitle - Destination.Unspecified -> mainAppState.currentLocation.name - }, - textAlign = if (mainAppState.currentLocation == Destination.CS || - mainAppState.currentLocation == Destination.SEARCH - ) { - TextAlign.Center - } else { - TextAlign.Start - }, - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.title - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - titleContentColor = MaterialTheme.colorScheme.title, - containerColor = MaterialTheme.colorScheme.containerBackgroundSolid - ), - actions = { - if (mainAppState.currentLocation == Destination.MAIN) { - IconButton( - onClick = { - navController.navigate(NavDestination(Destination.SEARCH)) - } - ) { - Image( - painter = painterResource(R.drawable.baseline_search_24), - contentDescription = "Search", - modifier = Modifier.wrapContentSize(), - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.title) - ) - } - IconButton( - onClick = { - navController.navigate(NavDestination(Destination.SETTINGS)) - } - ) { - Image( - painter = painterResource(R.drawable.baseline_settings_24), - contentDescription = "Settings", - modifier = Modifier.wrapContentSize(), - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.title) - ) - } - } - } - ) - }, - floatingActionButton = { - if (mainAppState.currentLocation == Destination.DETAILED && mainAppState.isFabVisible) { - FloatingActionButton( - onClick = { - if (mainAppState.tempReserveNoticeForBookmark.title.isNotBlank()) { - navController.navigate(mainAppState.tempReserveNoticeForBookmark) - } - }, - containerColor = if (mainAppState.tempReserveNoticeForBookmark.title.isNotBlank()) { - MaterialTheme.colorScheme.textPurple - } else { - MaterialTheme.colorScheme.subTitle - } - ) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = "Floating Action Button", - tint = Color.White - ) - } - } - }, - bottomBar = { - AnimatedVisibility( - visible = mainAppState.currentLocation == Destination.MAIN - || mainAppState.currentLocation == Destination.BOOKMARKS, - enter = slideInVertically(initialOffsetY = { it + (it * 1 / 2) }), - exit = slideOutVertically(targetOffsetY = { it + (it * 1 / 2) }) - ) { - BottomAppBar( - modifier = Modifier - .background(Color.Transparent), -// .clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)) -// .offset(y = 20.dp) - actions = { - BottomNavigationItem( - selected = mainAppState.currentLocation == Destination.MAIN, - enabled = true, - onClick = { - navController.navigate(NavDestination(Destination.MAIN)) - }, - icon = { - Icon( - painter = painterResource(R.drawable.baseline_home_24), - contentDescription = "Main", - modifier = Modifier.padding(bottom = 5.dp) - ) - }, - label = { - Text(stringResource(R.string.bottom_bar_home)) - }, - selectedContentColor = MaterialTheme.colorScheme.title, - unselectedContentColor = MaterialTheme.colorScheme.subTitle - ) - BottomNavigationItem( - selected = mainAppState.currentLocation == Destination.BOOKMARKS, - enabled = true, - onClick = { - navController.navigate(NavDestination(Destination.BOOKMARKS)) - }, - icon = { - Icon( - painter = painterResource(R.drawable.baseline_bookmarks_24), - contentDescription = "Bookmarks", - modifier = Modifier.padding(bottom = 5.dp) - ) - }, - label = { - Text(stringResource(R.string.bottom_bar_bookmark)) - }, - selectedContentColor = MaterialTheme.colorScheme.title, - unselectedContentColor = MaterialTheme.colorScheme.subTitle - ) - }, - containerColor = MaterialTheme.colorScheme.bottomNavContainer, - contentColor = MaterialTheme.colorScheme.title - ) - } - if (mainAppState.currentLocation != Destination.EDIT_BOOKMARK) { - - } - }, - containerColor = Color.Transparent - ) { innerPadding -> - - content(innerPadding) - - //TODO Live Translation Feature (TBD) -// AnimatedVisibility( -// visible = showLanguageDownloadRationale, -// enter = scaleIn(), -// exit = scaleOut() -// ) { -// Box( -// modifier = Modifier.fillMaxSize() -// .clickable { showLanguageDownloadRationale = false } -// ) { -// PermissionRationaleComposable( -// modifier = Modifier.align(Alignment.Center).padding(start = 20.dp, end = 20.dp), -// permissionName = stringResource(R.string.text_language), -// rationaleTitle = stringResource(R.string.title_langauge_model_download), -// description = stringResource(R.string.description_language_model_download) -// ) { -// viewModel.requestModelDownload() -// showLanguageDownloadRationale = true -// } -// } -// } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/NotificationPerferences.kt b/app/src/main/java/com/doyoonkim/knutice/presentation/NotificationPerferences.kt deleted file mode 100644 index 921020cf..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/presentation/NotificationPerferences.kt +++ /dev/null @@ -1,239 +0,0 @@ -package com.doyoonkim.knutice.presentation - -import android.content.Intent -import android.util.Log -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.layout.systemBarsPadding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -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.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.compose.LifecycleEventEffect -import com.doyoonkim.knutice.R -import com.doyoonkim.knutice.model.NoticeCategory -import com.doyoonkim.knutice.ui.theme.subTitle -import com.doyoonkim.knutice.ui.theme.title -import com.doyoonkim.knutice.viewModel.NotificationPreferenceViewModel - -@Composable -fun NotificationPreferences( - modifier: Modifier = Modifier, - viewModel: NotificationPreferenceViewModel = hiltViewModel(), - onBackClicked: () -> Unit = { }, - onMainNotificationSwitchToggled: () -> Unit = { } -) { - val context = LocalContext.current - val status by viewModel.uiStatus.collectAsState() - var permissionStatus by remember { mutableStateOf(false) } - - LaunchedEffect(Unit) { - viewModel.checkMainNotificationPreferenceStatus() - viewModel.checkTopicSubscriptionStatus() - } - - LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - viewModel.checkMainNotificationPreferenceStatus() - } - - Column( - modifier = Modifier.fillMaxSize() - .systemBarsPadding() - .padding(10.dp), - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top - ) { - Text( - modifier = Modifier.fillMaxWidth().wrapContentHeight(), - text = stringResource(R.string.pref_notification_title), - color = MaterialTheme.colorScheme.title, - fontWeight = FontWeight.SemiBold, - fontSize = 14.sp, - textAlign = TextAlign.Start - ) - - HorizontalDivider( - Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.subTitle - ) - - Column( - modifier = Modifier.fillMaxWidth().wrapContentSize() - .padding(top = 15.dp, bottom = 15.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier.wrapContentHeight().weight(5f), - verticalArrangement = Arrangement.spacedBy(5.dp) - ) { - Text( - modifier = Modifier.fillMaxWidth().wrapContentHeight(), - text = stringResource(R.string.enable_notification_title), - color = MaterialTheme.colorScheme.title, - fontWeight = FontWeight.Medium, - fontSize = 18.sp, - textAlign = TextAlign.Start - ) - - Text( - modifier = Modifier.fillMaxWidth().wrapContentHeight(), - text = stringResource(R.string.enable_service_notification_sub), - color = MaterialTheme.colorScheme.subTitle, - fontWeight = FontWeight.Normal, - fontSize = 12.sp, - textAlign = TextAlign.Start - ) - } - - Switch( - checked = status.isMainNotificationPermissionGranted, - onCheckedChange = { - val settingIntent = Intent( - "android.settings.APP_NOTIFICATION_SETTINGS" - ).apply { - this.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - this.putExtra( - "android.provider.extra.APP_PACKAGE", - context.packageName - ) - } - context.startActivity(settingIntent) - }, - enabled = true - ) - } - } - - LabeledToggleSwitch( - modifier = Modifier.padding(start = 10.dp), - titleText = stringResource(R.string.general_notificaiton_channel_name), - subTitleText = stringResource(R.string.general_notification_channel_description), - isSwitchChecked = status.isEachChannelAllowed[0], - isSwitchEnabled = status.isMainNotificationPermissionGranted - ) { - viewModel.updateChannelPreference(NoticeCategory.GENERAL_NEWS, it) - } - - LabeledToggleSwitch( - modifier = Modifier.padding(start = 10.dp), - titleText = stringResource(R.string.academic_notification_channel_name), - subTitleText = stringResource(R.string.academic_notification_channel_description), - isSwitchChecked = status.isEachChannelAllowed[1], - isSwitchEnabled = status.isMainNotificationPermissionGranted - ) { - viewModel.updateChannelPreference(NoticeCategory.ACADEMIC_NEWS, it) - } - LabeledToggleSwitch( - modifier = Modifier.padding(start = 10.dp), - titleText = stringResource(R.string.scholarship_notification_channel_name), - subTitleText = stringResource(R.string.scholarship_notification_channel_description), - isSwitchChecked = status.isEachChannelAllowed[2], - isSwitchEnabled = status.isMainNotificationPermissionGranted - ) { - viewModel.updateChannelPreference(NoticeCategory.SCHOLARSHIP_NEWS, it) - } - LabeledToggleSwitch( - modifier = Modifier.padding(start = 10.dp), - titleText = stringResource(R.string.event_notification_channel_name), - subTitleText = stringResource(R.string.event_notification_channel_description), - isSwitchChecked = status.isEachChannelAllowed[3], - isSwitchEnabled = status.isMainNotificationPermissionGranted - ) { - viewModel.updateChannelPreference(NoticeCategory.EVENT_NEWS, it) - } - } - } -} - -@Composable -fun LabeledToggleSwitch( - modifier: Modifier = Modifier, - titleText: String = "Title Text", - subTitleText: String = "Subtitle Text", - isSwitchChecked: Boolean = false, - isSwitchEnabled: Boolean = false, - onCheckStatusChanged: (Boolean) -> Unit = { } -) { - Column( - modifier = modifier.fillMaxWidth().wrapContentSize() - .padding(top = 15.dp, bottom = 15.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier.wrapContentHeight().weight(5f), - verticalArrangement = Arrangement.spacedBy(5.dp) - ) { - Text( - modifier = Modifier.fillMaxWidth().wrapContentHeight(), - text = titleText, - color = MaterialTheme.colorScheme.title, - fontWeight = FontWeight.Medium, - fontSize = 18.sp, - textAlign = TextAlign.Start - ) - - Text( - modifier = Modifier.fillMaxWidth().wrapContentHeight(), - text = subTitleText, - color = MaterialTheme.colorScheme.subTitle, - fontWeight = FontWeight.Normal, - fontSize = 12.sp, - textAlign = TextAlign.Start - ) - } - - Switch( - checked = isSwitchChecked, - onCheckedChange = { - onCheckStatusChanged(it) - }, - enabled = isSwitchEnabled - ) - } - } -} - -@Preview(locale = "ko-rKR") -@Composable -fun NotificationPreferences_Preview() { - NotificationPreferences() -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/OssNotice.kt b/app/src/main/java/com/doyoonkim/knutice/presentation/OssNotice.kt deleted file mode 100644 index 21c27196..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/presentation/OssNotice.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.doyoonkim.knutice.presentation - -import android.webkit.WebView -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView - -@Composable -fun OpenSourceLicenseNotice( - modifier: Modifier = Modifier -) { - AndroidView( - modifier = modifier.padding(), - factory = { context -> - WebView(context).apply { - loadUrl("https://knutice.github.io/KNUTICE-OpenSourceLicense/Android/opensource.html") - } - } - ) -} - -@Composable -@Preview -fun OpenSourceLicenseNotice_Preview() { - OpenSourceLicenseNotice() -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/viewModel/BookmarkViewModel.kt b/app/src/main/java/com/doyoonkim/knutice/viewModel/BookmarkViewModel.kt deleted file mode 100644 index 26ec5919..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/viewModel/BookmarkViewModel.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.doyoonkim.knutice.viewModel - -import android.util.Log -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.doyoonkim.knutice.data.NoticeLocalRepository -import com.doyoonkim.knutice.domain.FetchBookmarkFromDatabase -import com.doyoonkim.knutice.model.Bookmark -import com.doyoonkim.knutice.model.BookmarkComposableState -import com.doyoonkim.knutice.model.Notice -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class BookmarkViewModel @Inject constructor( - private val localRepository: NoticeLocalRepository, - private val fetchBookmarkFromDatabase: FetchBookmarkFromDatabase -): ViewModel() { - // Prevent direct access to the UI STATE from the 'View' - private var _uiState = MutableStateFlow(BookmarkComposableState()) - val uiState = _uiState.asStateFlow() - - init { - getAllBookmarks() - } - - fun updateBookmarks(newPair: Pair) { - _uiState.update { - it.copy( - bookmarks = uiState.value.bookmarks.toMutableList().apply { - this.add(newPair) - }.distinctBy { e -> e.first.bookmarkId }.toList() -// bookmarks = newList - ) - } - - Log.d("BookmarkViewModel", "Updated Bookmark Status: ${uiState.value.bookmarks}") - } - - fun getAllBookmarks() { - viewModelScope.launch(Dispatchers.IO) { - fetchBookmarkFromDatabase.getAllBookmarkWithNotices() - .map { Result.success(it) } - .catch { emit(Result.failure(it)) } - .collectLatest { result -> - result.fold( - onSuccess = { - Log.d("BookmarkViewModel", "Collected: ${it.toString()}") - updateBookmarks(it) - }, - onFailure = { - Log.d("BookmarkViewModel", "Unable to receive bookmark pair.\nREASON:${it.message}") - } - ) - } - } - } - -} - diff --git a/app/src/main/java/com/doyoonkim/knutice/viewModel/CategorizedNotificationViewModel.kt b/app/src/main/java/com/doyoonkim/knutice/viewModel/CategorizedNotificationViewModel.kt deleted file mode 100644 index e8c7367f..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/viewModel/CategorizedNotificationViewModel.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.doyoonkim.knutice.viewModel - -import android.util.Log -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.doyoonkim.knutice.domain.FetchTopThreeNoticeByCategory -import com.doyoonkim.knutice.model.Notice -import com.doyoonkim.knutice.model.NoticeCategory -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class CategorizedNotificationViewModel @Inject constructor( - private val fetchTopThreeNoticeUseCase: FetchTopThreeNoticeByCategory -) : ViewModel() { - init { - viewModelScope.launch(Dispatchers.Default) { - fetchTopThreeNoticesPerCategory(NoticeCategory.GENERAL_NEWS) - fetchTopThreeNoticesPerCategory(NoticeCategory.ACADEMIC_NEWS) - fetchTopThreeNoticesPerCategory(NoticeCategory.SCHOLARSHIP_NEWS) - fetchTopThreeNoticesPerCategory(NoticeCategory.EVENT_NEWS) - } - } - - private val fileName = "CategorizedNotificationViewModel" - private val _uiState = MutableStateFlow(CategorizedNotificationState()) - var uiState: StateFlow = _uiState.asStateFlow() - - fun updateState ( - updatedNotificationGeneral: List = _uiState.value.notificationGeneral, - updatedNotificationAcademic: List = _uiState.value.notificationAcademic, - updatedNotificationScholarship: List = _uiState.value.notificationScholarship, - updatedNotificationEvent: List = _uiState.value.notificationEvent - ) { - viewModelScope.launch(Dispatchers.Default) { - _uiState.update { - it.copy( - notificationGeneral = updatedNotificationGeneral, - notificationAcademic = updatedNotificationAcademic, - notificationScholarship = updatedNotificationScholarship, - notificationEvent = updatedNotificationEvent - ) - } - } - } - - private suspend fun fetchTopThreeNoticesPerCategory(category: NoticeCategory) { - fetchTopThreeNoticeUseCase.getTopThreeNotices(category) - .map { Result.success(it) } - .catch { emit(Result.failure(it)) } - .collectLatest { result -> - result.fold( - onSuccess = { - val notices = listOf(it.notice1!!, it.notice2!!, it.notice3!!) - when(category) { - NoticeCategory.GENERAL_NEWS -> { - - updateState( - updatedNotificationGeneral = notices - ) - } - NoticeCategory.ACADEMIC_NEWS -> updateState( - updatedNotificationAcademic = notices - ) - NoticeCategory.SCHOLARSHIP_NEWS -> updateState( - updatedNotificationScholarship = notices - ) - NoticeCategory.EVENT_NEWS -> updateState( - updatedNotificationEvent = notices - ) - else -> { } - } - }, - onFailure = { - Log.d(fileName, "Retrofit2: Failure: ${it.toString()}") - } - ) - } - } - -} - -data class CategorizedNotificationState( - val notificationGeneral: List = listOf(Notice(), Notice(), Notice()), - val notificationAcademic: List = listOf(Notice(), Notice(), Notice()), - val notificationScholarship: List = listOf(Notice(), Notice(), Notice()), - val notificationEvent: List = listOf(Notice(), Notice(), Notice()) -) \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/viewModel/CustomerServiceViewModel.kt b/app/src/main/java/com/doyoonkim/knutice/viewModel/CustomerServiceViewModel.kt deleted file mode 100644 index 663acc97..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/viewModel/CustomerServiceViewModel.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.doyoonkim.knutice.viewModel - -import android.content.Context -import android.os.Build -import android.util.Log -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.doyoonkim.knutice.R -import com.doyoonkim.knutice.data.KnuticeRemoteSource -import com.doyoonkim.knutice.model.CustomerServiceReportState -import com.doyoonkim.knutice.model.ReportRequest -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import javax.inject.Inject - -@HiltViewModel -class CustomerServiceViewModel @Inject constructor( - @ApplicationContext private val context: Context, - private val remoteSource: KnuticeRemoteSource -) : ViewModel() { - - private var _uiState = MutableStateFlow(CustomerServiceReportState()) - val uiState = _uiState.asStateFlow() - - fun updateUserReportContent(content: String) { - if (!_uiState.value.reachedMaxCharacters) { - viewModelScope.launch(Dispatchers.Default) { - _uiState.update { - it.copy( - userReport = content, - exceedMinCharacters = content.length >= 5, - reachedMaxCharacters = content.length >= 500 - ) - } - } - } - } - - fun updateCompletionState() { - _uiState.update { - it.copy( - isSubmissionCompleted = false, - isSubmissionFailed = false - ) - } - } - - fun submitUserReport() { - val report = ReportRequest( - content = _uiState.value.userReport, - deviceName = "${Build.BRAND} ${Build.MODEL}", - version = context.getString(R.string.version_code) - ) - - Log.d("CustomerServiceViewModel", "Generated Report:\n\t${report.toString()}") - - viewModelScope.launch { - withContext(Dispatchers.IO) { - val submission = async { remoteSource.submitUserReport(report) } - - submission.await().fold( - onSuccess = { submissionResult -> - if (submissionResult) { - _uiState.update { - it.copy( - userReport = "", - reachedMaxCharacters = false, - isSubmissionFailed = false, - isSubmissionCompleted = true - ) - } - } else { - _uiState.update { - it.copy( - isSubmissionFailed = true, - isSubmissionCompleted = true - ) - } - } - }, - onFailure = { - _uiState.update { - it.copy( - isSubmissionFailed = true, - isSubmissionCompleted = true - ) - } - } - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/viewModel/DetailedNoticeContentViewModel.kt b/app/src/main/java/com/doyoonkim/knutice/viewModel/DetailedNoticeContentViewModel.kt deleted file mode 100644 index 0af7ea17..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/viewModel/DetailedNoticeContentViewModel.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.doyoonkim.knutice.viewModel - -import android.util.Log -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.toRoute -import com.doyoonkim.knutice.data.KnuticeRemoteSource -import com.doyoonkim.knutice.domain.CrawlFullContentImpl -import com.doyoonkim.knutice.domain.FetchSingleNoticeImpl -import com.doyoonkim.knutice.model.DetailedContentState -import com.doyoonkim.knutice.model.FullContent -import com.doyoonkim.knutice.model.Notice -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class DetailedNoticeContentViewModel @Inject constructor( - private val fetchSingleNoticeImpl: FetchSingleNoticeImpl, - private val savedStateHandle: SavedStateHandle -) : ViewModel() { - - private var _uiState = MutableStateFlow(DetailedContentState()) - val uiState = _uiState.asStateFlow() - - private val requested = savedStateHandle.toRoute() - - init { - _uiState.update { - it.copy( - url = requested.url - ) - } - requestNoticeById(requested.nttId!!) - } - - fun updateLoadingStatus(newStatus: Int) { - Log.d("DetailedNoticeContentViewModel", "Update loading status") - viewModelScope.launch { - _uiState.update { - it.copy( - loadingStatue = (newStatus / 100).toFloat() - ) - } - } - } - - private fun requestNoticeById(nttId: String) { - viewModelScope.launch { - val requested = async { fetchSingleNoticeImpl.getSingleNoticeById(nttId) } - - _uiState.update { - it.copy( - requestedNotice = requested.await() - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/viewModel/EditBookmarkViewModel.kt b/app/src/main/java/com/doyoonkim/knutice/viewModel/EditBookmarkViewModel.kt deleted file mode 100644 index 1fff3b75..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/viewModel/EditBookmarkViewModel.kt +++ /dev/null @@ -1,166 +0,0 @@ -package com.doyoonkim.knutice.viewModel - -import android.util.Log -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.navigation.toRoute -import com.doyoonkim.knutice.data.NoticeLocalRepository -import com.doyoonkim.knutice.model.Bookmark -import com.doyoonkim.knutice.model.EditBookmarkState -import com.doyoonkim.knutice.model.Notice -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class EditBookmarkViewModel @Inject constructor( - private val localRepository: NoticeLocalRepository, - private val savedStateHandle: SavedStateHandle -) : ViewModel() { - - private var _uiState = MutableStateFlow(EditBookmarkState()) - val uiState: StateFlow = _uiState.asStateFlow() - - private val requestNotice = savedStateHandle.toRoute() - - init { - _uiState.update { - it.copy( - targetNotice = requestNotice - ) - } - getBookmarkByNotice() - } - - fun updateReminderOptions( - reminderRequested: Boolean = uiState.value.isReminderRequested, - updatedTimeForRemind: Long = uiState.value.timeForRemind, - updatedDatePickerVisible: Boolean = uiState.value.datePickerVisible - ) { - _uiState.update { - it.copy( - isReminderRequested = reminderRequested, - timeForRemind = updatedTimeForRemind, - datePickerVisible = updatedDatePickerVisible - ) - } - } - - fun updateBookmarkNotes(newString: String) { - viewModelScope.launch(Dispatchers.Default) { - if (uiState.value.bookmarkNote.length < 500) { - _uiState.update { - it.copy( - bookmarkNote = newString - ) - } - } - } - } - - private fun updateBookmarkInstance() { - val updatedInstance = Bookmark( - uiState.value.bookmarkId, - uiState.value.isReminderRequested, - uiState.value.timeForRemind, - uiState.value.bookmarkNote, - uiState.value.targetNotice.nttId - ) - _uiState.update { - it.copy( - bookmarkInstance = updatedInstance - ) - } - } - - private fun getBookmarkByNotice() { - viewModelScope.launch(Dispatchers.IO) { - localRepository.getBookmarkByNttID(requestNotice.nttId) - .fold( - onSuccess = { bookmark -> - if (bookmark.bookmarkId != -1) { - _uiState.update { - it.copy( - bookmarkId = bookmark.bookmarkId ?: 0, - isReminderRequested = bookmark.isScheduled ?: false, - timeForRemind = bookmark.reminderSchedule ?: 0, - bookmarkNote = bookmark.note ?: "", - requireCreation = false - ) - } - } - }, - onFailure = { - Log.d("EditBookmarkViewModel", "There is no existing bookmark for this notice.") - } - ) - } - } - - fun createNewBookmark() { - viewModelScope.launch(Dispatchers.IO) { - val targetNotice = uiState.value.targetNotice - val newBookmark = Bookmark( - bookmarkId = 0, - isScheduled = uiState.value.isReminderRequested, - reminderSchedule = 0, - note = uiState.value.bookmarkNote, - nttId = targetNotice.nttId - ) - - localRepository.createBookmark(newBookmark, targetNotice).fold( - onSuccess = { - Log.d("EditBookmarkViewModel", "Creation Inquiry Processed Successfully with state: $it") - updateBookmarkInstance() - }, - onFailure = { - Log.d("EditBookmarkViewModel", "Unable to process creation inquiry\nREASON:${it.message}") - } - ) - } - } - - fun modifyBookmark() { - viewModelScope.launch(Dispatchers.IO) { - val targetNotice = uiState.value.targetNotice - val modifiedBookmark = Bookmark( - bookmarkId = uiState.value.bookmarkId, - isScheduled = uiState.value.isReminderRequested, - reminderSchedule = uiState.value.timeForRemind, - note = uiState.value.bookmarkNote, - nttId = uiState.value.targetNotice.nttId - ) - - kotlin.runCatching { - localRepository.updateBookmark(modifiedBookmark) - updateBookmarkInstance() - }.onFailure { Log.d("EditBookmarkViewModel", "Unable to modify bookmark") } - - } - } - - fun removeBookmark() { - viewModelScope.launch(Dispatchers.IO) { - val targetBookmark = Bookmark( - bookmarkId = uiState.value.bookmarkId, - isScheduled = uiState.value.isReminderRequested, - reminderSchedule = uiState.value.timeForRemind, - note = uiState.value.bookmarkNote, - nttId = uiState.value.targetNotice.nttId - ) - - // The code snippet below should be isolated into separated class under the domain layer. - // Remove related notice entity first. - localRepository.deleteNoticeEntity(uiState.value.targetNotice.toNoticeEntity()) - // Once notice entity is being deleted, target bookmark would be deleted. - localRepository.deleteBookmark(targetBookmark) - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/viewModel/MainServiceViewModel.kt b/app/src/main/java/com/doyoonkim/knutice/viewModel/MainServiceViewModel.kt deleted file mode 100644 index 444cf3a7..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/viewModel/MainServiceViewModel.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.doyoonkim.knutice.viewModel - -import android.util.Log -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.doyoonkim.knutice.data.KnuticeRemoteSource -import com.doyoonkim.knutice.domain.FetchSingleNoticeImpl -import com.doyoonkim.knutice.model.Bookmark -import com.doyoonkim.knutice.model.Destination -import com.doyoonkim.knutice.model.Notice -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class MainServiceViewModel @Inject constructor( - private val fetchSingleNotice: FetchSingleNoticeImpl -) : ViewModel() { - private var _uiState = MutableStateFlow(MainServiceState()) - val uiState = _uiState.asStateFlow() - - // Targeted to be separated. (Causes multiple recomposition & state updates.) - fun updateState( - updatedCurrentLocation: Destination = _uiState.value.currentLocation, - updatedCurrentScaffoldTitle: String = _uiState.value.currentScaffoldTitle, - updatedBottomNavBarVisibility: Boolean = _uiState.value.isBottomNavBarVisible, - updatedFabVisibility: Boolean = _uiState.value.isFabVisible, - updatedTempReservedNoticeForBookmark: Notice = _uiState.value.tempReserveNoticeForBookmark, - updatedCurrentTargetBookmark: Bookmark = _uiState.value.currentTargetBookmark, - updatedScheduleTriggered: Boolean = _uiState.value.scheduleTriggered - ) { - _uiState.update { - it.copy( - currentLocation = updatedCurrentLocation, - currentScaffoldTitle = updatedCurrentScaffoldTitle, - isBottomNavBarVisible = updatedBottomNavBarVisibility, - isFabVisible = updatedFabVisibility, - tempReserveNoticeForBookmark = updatedTempReservedNoticeForBookmark, - currentTargetBookmark = updatedCurrentTargetBookmark, - scheduleTriggered = updatedScheduleTriggered - ) - } - } - - fun updateLanguageModelDownloadStatus(newStatus: String) { - _uiState.update { - it.copy( - languageModelDownloadResult = newStatus - ) - } - } - - fun getReservedNotice(nttId: String) { - viewModelScope.launch { - Log.d("MainServiceViewModel", "Start request reserved notice") - val request = async { - fetchSingleNotice.getSingleNoticeById(nttId) - }.await() - - _uiState.update { - it.copy( - tempReserveNoticeForBookmark = request, - currentScaffoldTitle = request.title - ) - } - } - } -} - -data class MainServiceState( - val currentLocation: Destination = Destination.MAIN, - val currentScaffoldTitle: String = "", - val isBottomNavBarVisible: Boolean = false, - val isFabVisible: Boolean = true, - val tempReserveNoticeForBookmark: Notice = Notice(), // For navigation to Edit Bookmark from DetailedContent (Since Detailed content requires FullContent to enter, while Edit Bookmark requires Notice to enter. - val currentTargetBookmark: Bookmark = Bookmark(-1), - val scheduleTriggered: Boolean = false, - val languageModelDownloadResult: String = "YET_STARTED" -) \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/viewModel/MoreCategorizedNotificationViewModel.kt b/app/src/main/java/com/doyoonkim/knutice/viewModel/MoreCategorizedNotificationViewModel.kt deleted file mode 100644 index 3d110589..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/viewModel/MoreCategorizedNotificationViewModel.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.doyoonkim.knutice.viewModel - -import android.util.Log -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.navigation.toRoute -import com.doyoonkim.knutice.domain.FetchNoticesPerPageInCategory -import com.doyoonkim.knutice.model.Destination -import com.doyoonkim.knutice.model.NavDestination -import com.doyoonkim.knutice.model.Notice -import com.doyoonkim.knutice.model.NoticeCategory -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class MoreCategorizedNotificationViewModel @Inject constructor( - private val fetchListOfNoticesUseCase: FetchNoticesPerPageInCategory, - private val savedStateHandle: SavedStateHandle -): ViewModel() { - private val filename = "MoreCategorizedNotificationViewModel" - - // UI State - private var _uiState = MutableStateFlow(MoreNotificationListState()) - val uiState = _uiState.asStateFlow() - - // Category of Requested Notice List - private val category = savedStateHandle.toRoute().run { - when (this.arrived) { - Destination.MORE_GENERAL -> NoticeCategory.GENERAL_NEWS - Destination.MORE_ACADEMIC -> NoticeCategory.ACADEMIC_NEWS - Destination.MORE_SCHOLARSHIP -> NoticeCategory.SCHOLARSHIP_NEWS - Destination.MORE_EVENT -> NoticeCategory.EVENT_NEWS - else -> NoticeCategory.Unspecified - } - } - - fun requestRefresh() { - _uiState.update { - it.copy( - currentLastNttId = 0, - notices = List(20) { Notice() }, - isRefreshRequested = true - ) - } - fetchNotificationPerPage() - } - - fun requestMoreNotices() { - if (!_uiState.value.isLoading) { - fetchNotificationPerPage() - } - } - - fun fetchNotificationPerPage() { - CoroutineScope(Dispatchers.IO).launch { - fetchListOfNoticesUseCase.getNoticesPerPage( - category, _uiState.value.currentLastNttId - ) - .map { Result.success(it) } - .catch { emit(Result.failure(it)) } - .collectLatest { result -> - result.fold( - onSuccess = { received -> - _uiState.update { - it.copy( - currentLastNttId = received.last().nttId, - notices = if (_uiState.value.currentLastNttId == 0) { - received - } else { - it.notices.addAll(received) - }, - isLoading = false, - isRefreshRequested = false - ) - } - }, - onFailure = { exception -> - Log.d(filename, "Unable to received data.\nReason: ${exception.message}") - _uiState.update { - it.copy(isLoading = false, isRefreshRequested = false) - } - } - ) - } - } - } - - private fun List.addAll(additionalElements: List): List { - return List(this.size + additionalElements.size) { - if (it in indices) this[it] - else additionalElements[it - this.size] - } - } - -} - -data class MoreNotificationListState( - val currentLastNttId: Int = 0, - val notices: List = List(20) { Notice() }, - val isLoading: Boolean = false, - val isRefreshRequested: Boolean = false -) \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/viewModel/NotificationPreferenceViewModel.kt b/app/src/main/java/com/doyoonkim/knutice/viewModel/NotificationPreferenceViewModel.kt deleted file mode 100644 index 113a4024..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/viewModel/NotificationPreferenceViewModel.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.doyoonkim.knutice.viewModel - -import android.Manifest -import android.app.NotificationManager -import android.content.Context -import android.content.Context.NOTIFICATION_SERVICE -import android.content.pm.PackageManager -import android.util.Log -import androidx.core.content.ContextCompat -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.doyoonkim.knutice.R -import com.doyoonkim.knutice.data.KnuticeRemoteSource -import com.doyoonkim.knutice.model.NoticeCategory -import com.doyoonkim.knutice.model.NotificationPreferenceStatus -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class NotificationPreferenceViewModel @Inject constructor( - @ApplicationContext private val context: Context, - private val remoteSource: KnuticeRemoteSource -) : ViewModel() { - - private var _uiStatus = MutableStateFlow(NotificationPreferenceStatus()) - val uiStatus = _uiStatus.asStateFlow() - - private val notificationChannels = hashMapOf( - NoticeCategory.GENERAL_NEWS to 0, - NoticeCategory.ACADEMIC_NEWS to 1, - NoticeCategory.SCHOLARSHIP_NEWS to 2, - NoticeCategory.EVENT_NEWS to 3 - ) - - - // Needed to be refined later. (If user enters this point without initial permission allowance. - fun checkMainNotificationPreferenceStatus() { - val isNotificationAllowed = ContextCompat.checkSelfPermission( - context, Manifest.permission.POST_NOTIFICATIONS - ) == PackageManager.PERMISSION_GRANTED - - val isMainChannelAllowed = (context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager) - .getNotificationChannel(context.getString(R.string.inapp_notification_channel_id)) - .importance > 0 - - if (isNotificationAllowed) { - if (!isMainChannelAllowed) updateMainNotificationStatus(false) - else updateMainNotificationStatus(true) - } else { - updateMainNotificationStatus(false) - } - } - - fun updateMainNotificationStatus(status: Boolean) { - _uiStatus.update { - it.copy( - isMainNotificationPermissionGranted = status - ) - } - } - - fun checkTopicSubscriptionStatus() { - viewModelScope.launch(Dispatchers.IO) { - remoteSource.getTopicSubscriptionStatus() - .fold( - onSuccess = { status -> - Log.d("NotificationPreferenceViewModel", "Status: ${status.body?.toString() ?: "empty"}") - _uiStatus.update { - it.copy( - isEachChannelAllowed = listOf( - status.body?.generalNewsTopic ?: false, - status.body?.academicNewsTopic ?: false, - status.body?.scholarshipNewsTopic ?: false, - status.body?.eventNewsTopic ?: false - ), - isSyncCompleted = true, - isError = false - ) - } - }, - onFailure = { - Log.d("NotificationPreferenceViewModel", "Unable to update preference status.") - _uiStatus.update { - it.copy( - isSyncCompleted = false, - isError = true - ) - } - } - ) - } - } - - fun updateChannelPreference(id: NoticeCategory, status: Boolean) { - val updatedStatus = List(_uiStatus.value.isEachChannelAllowed.size) { - if (it == notificationChannels[id]) status - else _uiStatus.value.isEachChannelAllowed[it] - } - - _uiStatus.update { - it.copy( - isEachChannelAllowed = updatedStatus - ) - } - - // Submit updates - CoroutineScope(Dispatchers.IO).launch { - val updateJob = launch { - remoteSource.submitTopicSubscriptionPreference( - id, status - ) - } - delay(5000L) - if (!updateJob.isCompleted) updateJob.cancelAndJoin() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/viewModel/SearchNoticeViewModel.kt b/app/src/main/java/com/doyoonkim/knutice/viewModel/SearchNoticeViewModel.kt deleted file mode 100644 index 65b43ac1..00000000 --- a/app/src/main/java/com/doyoonkim/knutice/viewModel/SearchNoticeViewModel.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.doyoonkim.knutice.viewModel - -import android.util.Log -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.doyoonkim.knutice.domain.QueryNoticesUsingKeyword -import com.doyoonkim.knutice.model.SearchNoticeState -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class SearchNoticeViewModel @Inject constructor( - private val queryNoticesUsingKeywordUseCase: QueryNoticesUsingKeyword -) : ViewModel() { - - // State - private var _uiState = MutableStateFlow(SearchNoticeState()) - val uiState = _uiState.asStateFlow() - - fun updateKeyword(newKeyword: String) { - _uiState.update { - it.copy( - searchKeyword = newKeyword - ) - } - } - - fun updateQueryStatue(newStatue: Boolean) = _uiState.update { it.copy(isQuerying = newStatue) } - - fun queryNoticeByKeyword(newKeyword: String) { - Log.d("SearchNoticeViewModel", "Query keyword ($newKeyword ) would be initiated.") - viewModelScope.launch { - updateQueryStatue(true) - queryNoticesUsingKeywordUseCase.getNoticesByKeyword(newKeyword) - .map { Result.success(it) } - .catch { emit(Result.failure(it)) } - .collectLatest { result -> - result.fold( - onSuccess = { list -> - _uiState.update { - it.copy( - queryResult = list, - isQuerying = false - ) - } - }, - onFailure = { - updateQueryStatue(false) - } - ) - } - } - } -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 404d250a..3c485967 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,10 +4,15 @@ plugins { alias(libs.plugins.kotlin.android) apply false // Dagger Hilt for Dependency Injection - id("com.google.dagger.hilt.android") version "2.51.1" apply false + id("com.google.dagger.hilt.android") version "2.56.2" apply false alias(libs.plugins.google.gms.google.services) apply false alias(libs.plugins.kotlinSerialization) apply false + alias(libs.plugins.android.library) apply false + + // Required from Kotlin 2.0.0 + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.jetbrains.kotlin.jvm) apply false // KSP Plugin for Room Database // id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false diff --git a/common/.gitignore b/common/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/common/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/common/build.gradle.kts b/common/build.gradle.kts new file mode 100644 index 00000000..12cf86d8 --- /dev/null +++ b/common/build.gradle.kts @@ -0,0 +1,75 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + id("kotlin-kapt") + + // Required from Kotlin 2.0.0 (Every module using Compose) + alias(libs.plugins.compose.compiler) + + alias(libs.plugins.kotlinSerialization) +} + +android { + namespace = "com.doyoonkim.common" + compileSdk = 35 + + defaultConfig { + minSdk = 30 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(platform(libs.androidx.compose.bom)) + + + testImplementation(libs.junit) + + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + + androidTestImplementation(platform(libs.androidx.compose.bom)) + + // Dagger + implementation(libs.dagger) + implementation(libs.dagger.android) + implementation(libs.dagger.android.support) + kapt(libs.dagger.compiler) + kapt(libs.dagger.android.processor) + + // Coil + implementation(libs.coil.compose) + + // Navigation For Compose + implementation(libs.androidx.navigation.compose) + + implementation(libs.kotlin.serialization) +} \ No newline at end of file diff --git a/common/consumer-rules.pro b/common/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/common/proguard-rules.pro b/common/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/common/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/common/src/androidTest/java/com/doyoonkim/common/ExampleInstrumentedTest.kt b/common/src/androidTest/java/com/doyoonkim/common/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..737f5b79 --- /dev/null +++ b/common/src/androidTest/java/com/doyoonkim/common/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.doyoonkim.common + +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("com.doyoonkim.common.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/common/src/main/AndroidManifest.xml b/common/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/common/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/common/src/main/java/com/doyoonkim/common/BitmapHandler.kt b/common/src/main/java/com/doyoonkim/common/BitmapHandler.kt new file mode 100644 index 00000000..fad3d092 --- /dev/null +++ b/common/src/main/java/com/doyoonkim/common/BitmapHandler.kt @@ -0,0 +1,18 @@ +package com.doyoonkim.common + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import javax.inject.Inject + +/** + * @author kimdoyoon + * Created 6/16/25 at 11:53 PM + */ +interface BitmapHandler { + fun decodeByteArray(byteArray: ByteArray): Bitmap +} + +class BitmapHandlerImpl @Inject constructor() : BitmapHandler { + override fun decodeByteArray(byteArray: ByteArray): Bitmap = + BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) +} diff --git a/common/src/main/java/com/doyoonkim/common/di/AppInjectorProvider.kt b/common/src/main/java/com/doyoonkim/common/di/AppInjectorProvider.kt new file mode 100644 index 00000000..4376e020 --- /dev/null +++ b/common/src/main/java/com/doyoonkim/common/di/AppInjectorProvider.kt @@ -0,0 +1,12 @@ +package com.doyoonkim.common.di + +/** + * Use it with extreme cautions + */ +interface AppInjectorProvider { + val appInjector: AppInjector +} + +interface AppInjector { + fun inject(target: Any) +} \ No newline at end of file diff --git a/common/src/main/java/com/doyoonkim/common/di/ApplicationContext.kt b/common/src/main/java/com/doyoonkim/common/di/ApplicationContext.kt new file mode 100644 index 00000000..5828207a --- /dev/null +++ b/common/src/main/java/com/doyoonkim/common/di/ApplicationContext.kt @@ -0,0 +1,7 @@ +package com.doyoonkim.common.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class ApplicationContext diff --git a/common/src/main/java/com/doyoonkim/common/di/CommonModule.kt b/common/src/main/java/com/doyoonkim/common/di/CommonModule.kt new file mode 100644 index 00000000..4e9f558a --- /dev/null +++ b/common/src/main/java/com/doyoonkim/common/di/CommonModule.kt @@ -0,0 +1,20 @@ +package com.doyoonkim.common.di + +import com.doyoonkim.common.BitmapHandler +import com.doyoonkim.common.BitmapHandlerImpl +import dagger.Module +import dagger.Binds + +/** + * @author kimdoyoon + * Created 6/17/25 at 12:14 AM + */ +@Module +abstract class CommonModule { + + @Binds + abstract fun bindsBitmapHandler( + impl: BitmapHandlerImpl + ): BitmapHandler + +} \ No newline at end of file diff --git a/common/src/main/java/com/doyoonkim/common/di/ViewModelKey.kt b/common/src/main/java/com/doyoonkim/common/di/ViewModelKey.kt new file mode 100644 index 00000000..87f61f6a --- /dev/null +++ b/common/src/main/java/com/doyoonkim/common/di/ViewModelKey.kt @@ -0,0 +1,11 @@ +package com.doyoonkim.common.di + +import androidx.lifecycle.ViewModel +import dagger.MapKey +import kotlin.reflect.KClass + +@MustBeDocumented +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@MapKey +annotation class ViewModelKey(val value: KClass) diff --git a/common/src/main/java/com/doyoonkim/common/navigation/BookmarkInfo.kt b/common/src/main/java/com/doyoonkim/common/navigation/BookmarkInfo.kt new file mode 100644 index 00000000..adeefd3b --- /dev/null +++ b/common/src/main/java/com/doyoonkim/common/navigation/BookmarkInfo.kt @@ -0,0 +1,10 @@ +package com.doyoonkim.common.navigation + +import kotlinx.serialization.Serializable + +@Serializable +data class BookmarkInfo( + val noticeId: Int, + val noticeTitle: String, + val noticeInfo: String +) diff --git a/common/src/main/java/com/doyoonkim/common/navigation/NavRoutes.kt b/common/src/main/java/com/doyoonkim/common/navigation/NavRoutes.kt new file mode 100644 index 00000000..05b443f4 --- /dev/null +++ b/common/src/main/java/com/doyoonkim/common/navigation/NavRoutes.kt @@ -0,0 +1,23 @@ +package com.doyoonkim.common.navigation + + +sealed class NavRoutes(val route: String) { + + data object Home : NavRoutes(Destination.HOME.name) + data object Bookmark: NavRoutes(Destination.BOOKMARKS.name) + + data object Settings: NavRoutes(Destination.SETTINGS.name) + data object NotificationPreferences: NavRoutes(Destination.NOTIFICATION.name) + data object CustomerService: NavRoutes(Destination.CS.name) + data object OpenSource: NavRoutes(Destination.OSS.name) + + data object GeneralNotices: NavRoutes(Destination.MORE_GENERAL.name) + data object AcademicNotices: NavRoutes(Destination.MORE_ACADEMIC.name) + data object ScholarshipNotices: NavRoutes(Destination.MORE_SCHOLARSHIP.name) + data object EventNotices: NavRoutes(Destination.MORE_EVENT.name) + + data object NoticeSearch: NavRoutes(Destination.SEARCH.name) +} + +enum class Destination { HOME, MORE_GENERAL, MORE_ACADEMIC, MORE_SCHOLARSHIP, MORE_EVENT, DETAILED, + SETTINGS, OSS, CS, SEARCH, NOTIFICATION, BOOKMARKS, EDIT_BOOKMARK, Unspecified } \ No newline at end of file diff --git a/common/src/main/java/com/doyoonkim/common/navigation/NoticeDetail.kt b/common/src/main/java/com/doyoonkim/common/navigation/NoticeDetail.kt new file mode 100644 index 00000000..18c264ae --- /dev/null +++ b/common/src/main/java/com/doyoonkim/common/navigation/NoticeDetail.kt @@ -0,0 +1,10 @@ +package com.doyoonkim.common.navigation + +import kotlinx.serialization.Serializable + +@Serializable +data class NoticeDetail( + val nttId: Int, + val contentUrl: String, + val isFabVisible: Boolean = true +) diff --git a/app/src/main/java/com/doyoonkim/knutice/ui/theme/Color.kt b/common/src/main/java/com/doyoonkim/common/theme/Color.kt similarity index 96% rename from app/src/main/java/com/doyoonkim/knutice/ui/theme/Color.kt rename to common/src/main/java/com/doyoonkim/common/theme/Color.kt index 3f67b694..cd9a5ec2 100644 --- a/app/src/main/java/com/doyoonkim/knutice/ui/theme/Color.kt +++ b/common/src/main/java/com/doyoonkim/common/theme/Color.kt @@ -1,4 +1,4 @@ -package com.doyoonkim.knutice.ui.theme +package com.doyoonkim.common.theme import androidx.compose.ui.graphics.Color diff --git a/app/src/main/java/com/doyoonkim/knutice/ui/theme/Theme.kt b/common/src/main/java/com/doyoonkim/common/theme/Theme.kt similarity index 97% rename from app/src/main/java/com/doyoonkim/knutice/ui/theme/Theme.kt rename to common/src/main/java/com/doyoonkim/common/theme/Theme.kt index 01d391f9..a3a58736 100644 --- a/app/src/main/java/com/doyoonkim/knutice/ui/theme/Theme.kt +++ b/common/src/main/java/com/doyoonkim/common/theme/Theme.kt @@ -1,4 +1,4 @@ -package com.doyoonkim.knutice.ui.theme +package com.doyoonkim.common.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme @@ -79,6 +79,10 @@ val ColorScheme.buttonContainer: Color @Composable get() = if(isSystemInDarkTheme()) ButtonDark else ButtonLight +val ColorScheme.buttonPurple: Color + @Composable + get() = Purple40 + val ColorScheme.textPurple: Color @Composable get() = if(isSystemInDarkTheme()) Purple80 else Purple40 diff --git a/app/src/main/java/com/doyoonkim/knutice/ui/theme/Type.kt b/common/src/main/java/com/doyoonkim/common/theme/Type.kt similarity index 96% rename from app/src/main/java/com/doyoonkim/knutice/ui/theme/Type.kt rename to common/src/main/java/com/doyoonkim/common/theme/Type.kt index 4a0a7bef..3031a5bb 100644 --- a/app/src/main/java/com/doyoonkim/knutice/ui/theme/Type.kt +++ b/common/src/main/java/com/doyoonkim/common/theme/Type.kt @@ -1,4 +1,4 @@ -package com.doyoonkim.knutice.ui.theme +package com.doyoonkim.common.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/component/AnimatedGradient.kt b/common/src/main/java/com/doyoonkim/common/ui/AnimatedGradient.kt similarity index 89% rename from app/src/main/java/com/doyoonkim/knutice/presentation/component/AnimatedGradient.kt rename to common/src/main/java/com/doyoonkim/common/ui/AnimatedGradient.kt index eeca8b8f..e91e905e 100644 --- a/app/src/main/java/com/doyoonkim/knutice/presentation/component/AnimatedGradient.kt +++ b/common/src/main/java/com/doyoonkim/common/ui/AnimatedGradient.kt @@ -1,4 +1,4 @@ -package com.doyoonkim.knutice.presentation.component +package com.doyoonkim.common.ui import androidx.compose.animation.animateColor import androidx.compose.animation.core.LinearEasing @@ -16,9 +16,9 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.unit.dp -import com.doyoonkim.knutice.ui.theme.animationGradientEnd -import com.doyoonkim.knutice.ui.theme.animationGradientIntermediate -import com.doyoonkim.knutice.ui.theme.animationGradientStart +import com.doyoonkim.common.theme.animationGradientEnd +import com.doyoonkim.common.theme.animationGradientIntermediate +import com.doyoonkim.common.theme.animationGradientStart @Composable fun AnimatedGradient( diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/component/DateTimePicker.kt b/common/src/main/java/com/doyoonkim/common/ui/DateTimePicker.kt similarity index 98% rename from app/src/main/java/com/doyoonkim/knutice/presentation/component/DateTimePicker.kt rename to common/src/main/java/com/doyoonkim/common/ui/DateTimePicker.kt index 77e17f33..5c698cd7 100644 --- a/app/src/main/java/com/doyoonkim/knutice/presentation/component/DateTimePicker.kt +++ b/common/src/main/java/com/doyoonkim/common/ui/DateTimePicker.kt @@ -1,4 +1,4 @@ -package com.doyoonkim.knutice.presentation.component +package com.doyoonkim.common.ui import android.util.Log import androidx.compose.foundation.background @@ -29,7 +29,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.doyoonkim.knutice.R +import com.doyoonkim.common.R import java.text.SimpleDateFormat import java.time.LocalDate import java.time.LocalDateTime diff --git a/common/src/main/java/com/doyoonkim/common/ui/LabeledToggleSwitch.kt b/common/src/main/java/com/doyoonkim/common/ui/LabeledToggleSwitch.kt new file mode 100644 index 00000000..d851c435 --- /dev/null +++ b/common/src/main/java/com/doyoonkim/common/ui/LabeledToggleSwitch.kt @@ -0,0 +1,80 @@ +package com.doyoonkim.common.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.doyoonkim.common.theme.buttonPurple +import com.doyoonkim.common.theme.subTitle +import com.doyoonkim.common.theme.title + +@Composable +fun LabeledToggleSwitch( + modifier: Modifier = Modifier, + titleText: String = "Title Text", + subTitleText: String = "Subtitle Text", + isChecked: Boolean = false, + isEnabled: Boolean = false, + onCheckStatusChanged: (Boolean) -> Unit +) { + Column( + modifier = modifier.fillMaxWidth().wrapContentSize() + .padding(top = 15.dp, bottom = 15.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.wrapContentHeight().weight(5f), + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + Text( + modifier = Modifier.fillMaxWidth().wrapContentHeight(), + text = titleText, + color = MaterialTheme.colorScheme.title, + fontWeight = FontWeight.Medium, + fontSize = 18.sp, + textAlign = TextAlign.Start + ) + + Text( + modifier = Modifier.fillMaxWidth().wrapContentHeight(), + text = subTitleText, + color = MaterialTheme.colorScheme.subTitle, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + textAlign = TextAlign.Start + ) + } + + Switch( + checked = isChecked, + colors = SwitchDefaults.colors().copy( + checkedTrackColor = MaterialTheme.colorScheme.buttonPurple, + checkedThumbColor = Color.White + ), + onCheckedChange = { + onCheckStatusChanged(it) + }, + enabled = isEnabled + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/component/LazyText.kt b/common/src/main/java/com/doyoonkim/common/ui/LazyText.kt similarity index 96% rename from app/src/main/java/com/doyoonkim/knutice/presentation/component/LazyText.kt rename to common/src/main/java/com/doyoonkim/common/ui/LazyText.kt index fbdfe5e4..1f037a35 100644 --- a/app/src/main/java/com/doyoonkim/knutice/presentation/component/LazyText.kt +++ b/common/src/main/java/com/doyoonkim/common/ui/LazyText.kt @@ -1,4 +1,4 @@ -package com.doyoonkim.knutice.presentation.component +package com.doyoonkim.common.ui import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/component/NotificationPreview.kt b/common/src/main/java/com/doyoonkim/common/ui/NotificationPreview.kt similarity index 84% rename from app/src/main/java/com/doyoonkim/knutice/presentation/component/NotificationPreview.kt rename to common/src/main/java/com/doyoonkim/common/ui/NotificationPreview.kt index 86fa71df..a41084f6 100644 --- a/app/src/main/java/com/doyoonkim/knutice/presentation/component/NotificationPreview.kt +++ b/common/src/main/java/com/doyoonkim/common/ui/NotificationPreview.kt @@ -1,4 +1,4 @@ -package com.doyoonkim.knutice.presentation.component +package com.doyoonkim.common.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -18,19 +18,20 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage +import coil.request.CachePolicy import coil.request.ImageRequest -import com.doyoonkim.knutice.ui.theme.subTitle -import com.doyoonkim.knutice.ui.theme.title +import com.doyoonkim.common.theme.subTitle +import com.doyoonkim.common.theme.title @Composable fun NotificationPreview( modifier: Modifier = Modifier, + isLoading: Boolean = true, isImageContained: Boolean = false, - notificationTitle: String = "Title goes here.", + notificationTitle: String = "Unknown", notificationInfo: String = "Notification info goes here.", imageUrl: String = "" ) { @@ -38,7 +39,7 @@ fun NotificationPreview( Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(7.dp) ) { - if (notificationTitle == "Unknown") { + if (isLoading) { AnimatedGradient(Modifier.height(24.dp)) AnimatedGradient(Modifier.height(14.dp)) } else { @@ -50,6 +51,8 @@ fun NotificationPreview( AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(imageUrl) + .diskCachePolicy(CachePolicy.DISABLED) + .memoryCachePolicy(CachePolicy.READ_ONLY) .crossfade(true) .build(), contentDescription = "Contained Image", @@ -64,7 +67,7 @@ fun NotificationPreview( .padding(top = 7.dp, start = 5.dp, end = 5.dp), text = notificationTitle, textAlign = TextAlign.Start, - fontSize = 14.sp, + fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.title, maxLines = 1, @@ -72,18 +75,12 @@ fun NotificationPreview( ) Text( modifier = Modifier.fillMaxWidth() - .padding(start = 5.dp, bottom = 5.dp, end = 5.dp), + .padding(top = 1.dp, start = 5.dp, bottom = 5.dp, end = 5.dp), text = notificationInfo, textAlign = TextAlign.Start, - fontSize = 12.sp, + fontSize = 9.sp, color = MaterialTheme.colorScheme.subTitle ) } } -} - -@Preview -@Composable -fun NotificationPreview_Preview() { - NotificationPreview() } \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/component/NotificationPreviewCard.kt b/common/src/main/java/com/doyoonkim/common/ui/NotificationPreviewCard.kt similarity index 83% rename from app/src/main/java/com/doyoonkim/knutice/presentation/component/NotificationPreviewCard.kt rename to common/src/main/java/com/doyoonkim/common/ui/NotificationPreviewCard.kt index 4e9dbb66..55f0311d 100644 --- a/app/src/main/java/com/doyoonkim/knutice/presentation/component/NotificationPreviewCard.kt +++ b/common/src/main/java/com/doyoonkim/common/ui/NotificationPreviewCard.kt @@ -1,4 +1,4 @@ -package com.doyoonkim.knutice.presentation.component +package com.doyoonkim.common.ui import android.content.res.Configuration import androidx.compose.foundation.clickable @@ -13,11 +13,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.doyoonkim.knutice.ui.theme.containerBackground +import com.doyoonkim.common.theme.containerBackground @Composable fun NotificationPreviewCard( modifier: Modifier = Modifier, + isLoading: Boolean = true, notificationTitle: String = "Title goes here.", notificationInfo: String = "Notification info goes here.", onClick: () -> Unit = { /* Action should be defined. */ } @@ -36,10 +37,11 @@ fun NotificationPreviewCard( ), shape = RoundedCornerShape(10.dp) ) { - NotificationPreview( - notificationTitle = notificationTitle, - notificationInfo = notificationInfo - ) + NotificationPreview( + notificationTitle = notificationTitle, + notificationInfo = notificationInfo, + isLoading = isLoading + ) } } diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/component/NotificationPreviewCardMakred.kt b/common/src/main/java/com/doyoonkim/common/ui/NotificationPreviewCardMarked.kt similarity index 92% rename from app/src/main/java/com/doyoonkim/knutice/presentation/component/NotificationPreviewCardMakred.kt rename to common/src/main/java/com/doyoonkim/common/ui/NotificationPreviewCardMarked.kt index 2017e6a0..38660831 100644 --- a/app/src/main/java/com/doyoonkim/knutice/presentation/component/NotificationPreviewCardMakred.kt +++ b/common/src/main/java/com/doyoonkim/common/ui/NotificationPreviewCardMarked.kt @@ -1,4 +1,4 @@ -package com.doyoonkim.knutice.presentation.component +package com.doyoonkim.common.ui import android.content.res.Configuration import androidx.compose.foundation.Image @@ -13,11 +13,12 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.doyoonkim.knutice.R +import com.doyoonkim.common.R @Composable fun NotificationPreviewCardMarked( modifier: Modifier = Modifier, + isLoading: Boolean = false, noticeTitle: String = "Title goes here", noticeSubtitle: String = "Subtitle goes here", onItemClicked: () -> Unit = { }, @@ -28,6 +29,7 @@ fun NotificationPreviewCardMarked( contentAlignment = Alignment.TopEnd ) { NotificationPreviewCard( + isLoading = isLoading, notificationTitle = noticeTitle, notificationInfo = noticeSubtitle, ) { diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/component/PermissionRationaleComposable.kt b/common/src/main/java/com/doyoonkim/common/ui/PermissionRationaleComposable.kt similarity index 90% rename from app/src/main/java/com/doyoonkim/knutice/presentation/component/PermissionRationaleComposable.kt rename to common/src/main/java/com/doyoonkim/common/ui/PermissionRationaleComposable.kt index a76c037b..3321d7cb 100644 --- a/app/src/main/java/com/doyoonkim/knutice/presentation/component/PermissionRationaleComposable.kt +++ b/common/src/main/java/com/doyoonkim/common/ui/PermissionRationaleComposable.kt @@ -1,4 +1,4 @@ -package com.doyoonkim.knutice.presentation.component +package com.doyoonkim.common.ui import android.content.res.Configuration import androidx.compose.foundation.background @@ -10,9 +10,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Surface import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -24,7 +25,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.doyoonkim.knutice.R +import com.doyoonkim.common.R +import com.doyoonkim.common.theme.buttonPurple @Composable fun PermissionRationaleComposable( @@ -70,6 +72,10 @@ fun PermissionRationaleComposable( ) Button( modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors().copy( + containerColor = MaterialTheme.colorScheme.buttonPurple, + contentColor = Color.White, + ), onClick = onPermissionDecided ) { Text( diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/component/PopUpDialog.kt b/common/src/main/java/com/doyoonkim/common/ui/PopUpDialog.kt similarity index 90% rename from app/src/main/java/com/doyoonkim/knutice/presentation/component/PopUpDialog.kt rename to common/src/main/java/com/doyoonkim/common/ui/PopUpDialog.kt index 05b6a0a6..e9e2925e 100644 --- a/app/src/main/java/com/doyoonkim/knutice/presentation/component/PopUpDialog.kt +++ b/common/src/main/java/com/doyoonkim/common/ui/PopUpDialog.kt @@ -1,6 +1,5 @@ -package com.doyoonkim.knutice.presentation.component +package com.doyoonkim.common.ui -import android.graphics.Paint.Align import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically @@ -9,7 +8,6 @@ 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -26,17 +24,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role.Companion.Button import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.doyoonkim.knutice.R - -data class PopupDialogState( - val isPopupDialogDisplayed: Boolean = false, - val isPopupDialogMutedForDay: Boolean = true -) +import com.doyoonkim.common.R @Composable fun PopUpDialog( @@ -114,5 +105,4 @@ fun PopUpDialog_Preview() { } } } -} - +} \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_add_circle_24.xml b/common/src/main/res/drawable/baseline_add_circle_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_add_circle_24.xml rename to common/src/main/res/drawable/baseline_add_circle_24.xml diff --git a/app/src/main/res/drawable/baseline_arrow_back_ios_new_24.xml b/common/src/main/res/drawable/baseline_arrow_back_ios_new_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_arrow_back_ios_new_24.xml rename to common/src/main/res/drawable/baseline_arrow_back_ios_new_24.xml diff --git a/app/src/main/res/drawable/baseline_arrow_forward_ios_24.xml b/common/src/main/res/drawable/baseline_arrow_forward_ios_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_arrow_forward_ios_24.xml rename to common/src/main/res/drawable/baseline_arrow_forward_ios_24.xml diff --git a/app/src/main/res/drawable/baseline_bookmarks_24.xml b/common/src/main/res/drawable/baseline_bookmarks_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_bookmarks_24.xml rename to common/src/main/res/drawable/baseline_bookmarks_24.xml diff --git a/app/src/main/res/drawable/baseline_close_24.xml b/common/src/main/res/drawable/baseline_close_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_close_24.xml rename to common/src/main/res/drawable/baseline_close_24.xml diff --git a/app/src/main/res/drawable/baseline_home_24.xml b/common/src/main/res/drawable/baseline_home_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_home_24.xml rename to common/src/main/res/drawable/baseline_home_24.xml diff --git a/app/src/main/res/drawable/baseline_search_24.xml b/common/src/main/res/drawable/baseline_search_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_search_24.xml rename to common/src/main/res/drawable/baseline_search_24.xml diff --git a/app/src/main/res/drawable/baseline_settings_24.xml b/common/src/main/res/drawable/baseline_settings_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_settings_24.xml rename to common/src/main/res/drawable/baseline_settings_24.xml diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/common/src/main/res/drawable/ic_launcher_background.xml similarity index 100% rename from app/src/main/res/drawable/ic_launcher_background.xml rename to common/src/main/res/drawable/ic_launcher_background.xml diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/common/src/main/res/drawable/ic_launcher_foreground.xml similarity index 100% rename from app/src/main/res/drawable/ic_launcher_foreground.xml rename to common/src/main/res/drawable/ic_launcher_foreground.xml diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/common/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to common/src/main/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/common/src/main/res/mipmap-anydpi/ic_launcher_round.xml similarity index 100% rename from app/src/main/res/mipmap-anydpi/ic_launcher_round.xml rename to common/src/main/res/mipmap-anydpi/ic_launcher_round.xml diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/common/src/main/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from app/src/main/res/mipmap-hdpi/ic_launcher.png rename to common/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/common/src/main/res/mipmap-hdpi/ic_launcher_background.png similarity index 100% rename from app/src/main/res/mipmap-hdpi/ic_launcher_background.png rename to common/src/main/res/mipmap-hdpi/ic_launcher_background.png diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/common/src/main/res/mipmap-hdpi/ic_launcher_foreground.png similarity index 100% rename from app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png rename to common/src/main/res/mipmap-hdpi/ic_launcher_foreground.png diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/common/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png similarity index 100% rename from app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png rename to common/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/common/src/main/res/mipmap-hdpi/ic_launcher_round.webp similarity index 100% rename from app/src/main/res/mipmap-hdpi/ic_launcher_round.webp rename to common/src/main/res/mipmap-hdpi/ic_launcher_round.webp diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/common/src/main/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from app/src/main/res/mipmap-mdpi/ic_launcher.png rename to common/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/common/src/main/res/mipmap-mdpi/ic_launcher_background.png similarity index 100% rename from app/src/main/res/mipmap-mdpi/ic_launcher_background.png rename to common/src/main/res/mipmap-mdpi/ic_launcher_background.png diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/common/src/main/res/mipmap-mdpi/ic_launcher_foreground.png similarity index 100% rename from app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png rename to common/src/main/res/mipmap-mdpi/ic_launcher_foreground.png diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/common/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png similarity index 100% rename from app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png rename to common/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/common/src/main/res/mipmap-mdpi/ic_launcher_round.webp similarity index 100% rename from app/src/main/res/mipmap-mdpi/ic_launcher_round.webp rename to common/src/main/res/mipmap-mdpi/ic_launcher_round.webp diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/common/src/main/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from app/src/main/res/mipmap-xhdpi/ic_launcher.png rename to common/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/common/src/main/res/mipmap-xhdpi/ic_launcher_background.png similarity index 100% rename from app/src/main/res/mipmap-xhdpi/ic_launcher_background.png rename to common/src/main/res/mipmap-xhdpi/ic_launcher_background.png diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/common/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png similarity index 100% rename from app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png rename to common/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/common/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png similarity index 100% rename from app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png rename to common/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/common/src/main/res/mipmap-xhdpi/ic_launcher_round.webp similarity index 100% rename from app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp rename to common/src/main/res/mipmap-xhdpi/ic_launcher_round.webp diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/common/src/main/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from app/src/main/res/mipmap-xxhdpi/ic_launcher.png rename to common/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/common/src/main/res/mipmap-xxhdpi/ic_launcher_background.png similarity index 100% rename from app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png rename to common/src/main/res/mipmap-xxhdpi/ic_launcher_background.png diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/common/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png similarity index 100% rename from app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png rename to common/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/common/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png similarity index 100% rename from app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png rename to common/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/common/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp similarity index 100% rename from app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp rename to common/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/common/src/main/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from app/src/main/res/mipmap-xxxhdpi/ic_launcher.png rename to common/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/common/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png similarity index 100% rename from app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png rename to common/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/common/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png similarity index 100% rename from app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png rename to common/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/common/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png similarity index 100% rename from app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png rename to common/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/common/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp similarity index 100% rename from app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp rename to common/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp diff --git a/app/src/main/res/values-ja/strings.xml b/common/src/main/res/values-ja/strings.xml similarity index 97% rename from app/src/main/res/values-ja/strings.xml rename to common/src/main/res/values-ja/strings.xml index 5c691206..56e9eef5 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/common/src/main/res/values-ja/strings.xml @@ -61,4 +61,6 @@ More KNUTICE Notice 選択したファイルをダウンロード フォルダーにダウンロードします。 + 確認完了 + 確認不可能 \ No newline at end of file diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/common/src/main/res/values-ko-rKR/strings.xml similarity index 96% rename from app/src/main/res/values-ko-rKR/strings.xml rename to common/src/main/res/values-ko-rKR/strings.xml index 96a1ff1b..62eaf17e 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/common/src/main/res/values-ko-rKR/strings.xml @@ -61,4 +61,6 @@ 한국어 번역 기능을 사용하려면 언어 모델을 다운로드 해야 합니다. 선택한 파일을 다운로드 폴더에 받고 있어요. + 저장이 완료되었어요. + 저장하는 중 문제가 생겼어요. \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/common/src/main/res/values/colors.xml similarity index 100% rename from app/src/main/res/values/colors.xml rename to common/src/main/res/values/colors.xml diff --git a/app/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml similarity index 96% rename from app/src/main/res/values/strings.xml rename to common/src/main/res/values/strings.xml index 78335f26..494e1464 100644 --- a/app/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -11,7 +11,7 @@ About Version Open Source License - 1.4.1 + 1.4.2 Notification Preference New Notice has been delivered! @@ -71,4 +71,6 @@ English Language model can be downloaded to use live translation Downloading the selected file to Downloads... + Successfully saved. + Unable to save. \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/common/src/main/res/values/themes.xml similarity index 100% rename from app/src/main/res/values/themes.xml rename to common/src/main/res/values/themes.xml diff --git a/app/src/test/java/com/doyoonkim/knutice/ExampleUnitTest.kt b/common/src/test/java/com/doyoonkim/common/ExampleUnitTest.kt similarity index 91% rename from app/src/test/java/com/doyoonkim/knutice/ExampleUnitTest.kt rename to common/src/test/java/com/doyoonkim/common/ExampleUnitTest.kt index c13b244f..950d083d 100644 --- a/app/src/test/java/com/doyoonkim/knutice/ExampleUnitTest.kt +++ b/common/src/test/java/com/doyoonkim/common/ExampleUnitTest.kt @@ -1,4 +1,4 @@ -package com.doyoonkim.knutice +package com.doyoonkim.common import org.junit.Test diff --git a/core/data/.gitignore b/core/data/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/data/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts new file mode 100644 index 00000000..35d2c98c --- /dev/null +++ b/core/data/build.gradle.kts @@ -0,0 +1,65 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + + id("kotlin-kapt") +} + +android { + namespace = "com.doyoonkim.data" + compileSdk = 35 + + defaultConfig { + minSdk = 30 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + implementation(projects.core.model) + implementation(projects.core.network) + implementation(projects.core.domain) // Dependency Inversion + implementation(projects.common) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + + // Dagger + implementation(libs.dagger) + implementation(libs.dagger.android) + implementation(libs.dagger.android.support) + kapt(libs.dagger.compiler) + kapt(libs.dagger.android.processor) + + // Coroutines for Android + implementation(libs.kotlinx.coroutines.android) + + // Room Database + implementation(libs.androidx.room.runtime) + kapt(libs.androidx.room.compiler) + // Room Database - Kotlin Extensions and Coroutine Support + implementation(libs.androidx.room.ktx) +} \ No newline at end of file diff --git a/core/data/consumer-rules.pro b/core/data/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/core/data/proguard-rules.pro b/core/data/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/core/data/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/data/src/androidTest/java/com/doyoonkim/data/ExampleInstrumentedTest.kt b/core/data/src/androidTest/java/com/doyoonkim/data/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..a492d76e --- /dev/null +++ b/core/data/src/androidTest/java/com/doyoonkim/data/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.doyoonkim.data + +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("com.doyoonkim.data.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/core/data/src/main/AndroidManifest.xml b/core/data/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/core/data/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/core/data/src/main/java/com/doyoonkim/data/di/dataModule.kt b/core/data/src/main/java/com/doyoonkim/data/di/dataModule.kt new file mode 100644 index 00000000..e42ea1b3 --- /dev/null +++ b/core/data/src/main/java/com/doyoonkim/data/di/dataModule.kt @@ -0,0 +1,69 @@ +package com.doyoonkim.data.di + +import android.content.Context +import android.util.Log +import androidx.room.Room +import com.doyoonkim.common.di.ApplicationContext +import com.doyoonkim.data.repository.ImageRepositoryImpl +import com.doyoonkim.data.repository.LocalRepositoryImpl +import com.doyoonkim.data.repository.RemoteRepositoryImpl +import com.doyoonkim.data.room.LocalDatabase +import com.doyoonkim.data.room.MainDatabaseDao +import com.doyoonkim.domain.ImageRepository +import com.doyoonkim.domain.LocalRepository +import com.doyoonkim.domain.RemoteRepository +import com.doyoonkim.network.ImageRemoteSource +import com.doyoonkim.network.KnuticeRemoteSource +import dagger.Module +import dagger.Provides +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import javax.inject.Singleton + +@Module +object DataModule { + + // Database + @Provides + @Singleton + fun provideLocalDatabase( + @ApplicationContext context: Context + ): LocalDatabase { + // @Provides annotation is used because providing LocalDatabase instance requires custom-logic for instantiation. + return Room.databaseBuilder( + context, + LocalDatabase::class.java, + "Main Local Database" + ).setQueryCallback( + { sqlQuery, bindArgs -> + Log.d("SQL", "Query: $sqlQuery SQLArgs: $bindArgs") + }, + Dispatchers.IO.asExecutor() + ).build() + } + + @Provides + fun provideMainDatabaseDao(db: LocalDatabase) = db.getDao() + + @Provides + fun provideLocalRepository( + localDao: MainDatabaseDao + ): LocalRepository { + return LocalRepositoryImpl(localDao) + } + + @Provides + fun provideRemoteRepository( + remoteSource: KnuticeRemoteSource + ): RemoteRepository { + return RemoteRepositoryImpl(remoteSource) + } + + @Provides + fun providesImageRepository( + imageRemoteSource: ImageRemoteSource + ): ImageRepository { + return ImageRepositoryImpl(imageRemoteSource) + } + +} \ No newline at end of file diff --git a/core/data/src/main/java/com/doyoonkim/data/model/Bookmark.kt b/core/data/src/main/java/com/doyoonkim/data/model/Bookmark.kt new file mode 100644 index 00000000..29b3970f --- /dev/null +++ b/core/data/src/main/java/com/doyoonkim/data/model/Bookmark.kt @@ -0,0 +1,14 @@ +package com.doyoonkim.data.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class Bookmark( + @PrimaryKey(autoGenerate = true) val bookmarkId: Int = 0, + @ColumnInfo("isScheduled") val isScheduled: Boolean = false, + @ColumnInfo("remind_schedule") val reminderSchedule: Long = 0, + @ColumnInfo("bookmark_note") val note: String = "", + @ColumnInfo("target_ntt_id") val nttId: Int = -1 +) diff --git a/core/data/src/main/java/com/doyoonkim/data/model/NoticeEntity.kt b/core/data/src/main/java/com/doyoonkim/data/model/NoticeEntity.kt new file mode 100644 index 00000000..c5844a69 --- /dev/null +++ b/core/data/src/main/java/com/doyoonkim/data/model/NoticeEntity.kt @@ -0,0 +1,16 @@ +package com.doyoonkim.data.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class NoticeEntity( + @PrimaryKey(autoGenerate = true) val noticeEntityId: Int = 0, + @ColumnInfo("ntt_id") val nttId: Int = -1, + @ColumnInfo("notice_title") val title: String, + @ColumnInfo("notice_url") val url: String, + @ColumnInfo("notice_image") val imageUrl: String, + @ColumnInfo("info_dept") val departName: String, + @ColumnInfo("info_timestamp") val timestamp: String +) diff --git a/core/data/src/main/java/com/doyoonkim/data/repository/ImageRepositoryImpl.kt b/core/data/src/main/java/com/doyoonkim/data/repository/ImageRepositoryImpl.kt new file mode 100644 index 00000000..3f549697 --- /dev/null +++ b/core/data/src/main/java/com/doyoonkim/data/repository/ImageRepositoryImpl.kt @@ -0,0 +1,32 @@ +package com.doyoonkim.data.repository + +import android.util.Log +import com.doyoonkim.domain.ImageRepository +import com.doyoonkim.network.ImageRemoteSource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +/** + * @author kimdoyoon + * Created 6/17/25 at 12:41 AM + */ +class ImageRepositoryImpl @Inject constructor( + private val remoteSource: ImageRemoteSource +) : ImageRepository { + override suspend fun getImageByteArrayFromUrl(url: String): ByteArray? { + remoteSource.getByteArrayFromImageUrl(url) + .fold( + onSuccess = { + return it + }, + onFailure = { + Log.d( + "ImageRepositoryImpl", + "Unable to get ByteArray from the given url\nREASON: ${it.stackTrace}" + ) + return null + } + ) + } +} \ No newline at end of file diff --git a/core/data/src/main/java/com/doyoonkim/data/repository/LocalRepositoryImpl.kt b/core/data/src/main/java/com/doyoonkim/data/repository/LocalRepositoryImpl.kt new file mode 100644 index 00000000..786643df --- /dev/null +++ b/core/data/src/main/java/com/doyoonkim/data/repository/LocalRepositoryImpl.kt @@ -0,0 +1,151 @@ +package com.doyoonkim.data.repository + +import android.util.Log +import com.doyoonkim.data.model.Bookmark +import com.doyoonkim.data.model.NoticeEntity +import com.doyoonkim.data.room.MainDatabaseDao +import com.doyoonkim.domain.LocalRepository +import com.doyoonkim.model.BookmarkVO +import com.doyoonkim.model.NoticeVO +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +class LocalRepositoryImpl @Inject constructor( + // Inject Local Database from the app module (planned) + private val localDao: MainDatabaseDao +) : LocalRepository { + private val TAG = "LocalRepositoryImpl" + + // CRUD + override fun createBookmark(bookmark: BookmarkVO) = flow { + runCatching { + localDao.createBookmark(bookmark.toBookmark()) + }.onFailure { throw it }.fold( + onSuccess = { emit(true) }, + onFailure = { it.printLog().also { emit(false) } } + ) + } + + override fun createBookmark(bookmark: BookmarkVO, targetNotice: NoticeVO) = flow { + runCatching { + localDao.run { + // Insert Notice Entity First + createNoticeEntity(targetNotice.toNoticeEntity()) + // Insert Bookmark Entity next. + this.createBookmark(bookmark.toBookmark()) + } + }.onFailure { throw it }.fold( + onSuccess = { emit(true) }, + onFailure = { it.printLog().also { emit(false) } } + ) + } + + override fun updateBookmark(bookmark: BookmarkVO) = flow { + runCatching { + localDao.updateBookmark(bookmark.toBookmark()) + }.onFailure { throw it }.fold( + onSuccess = { emit(true) }, + onFailure = { it.printLog().also { emit(false) } } + ) + } + + override fun queryAllBookmarks() = flow { + runCatching { + localDao.getAllBookmarks() + }.onFailure { throw it }.fold( + onSuccess = { + Log.d(TAG, "${it.size}") + it.toListOfBookmarkVO().forEach { + vo -> emit(vo).also { Log.d(TAG, vo.toString()) } + } }, + onFailure = { it.printLog().also { emit(null) } } + ) + } + + override fun queryNoticeById(nttId: Int) = flow { + runCatching { + localDao.getNoticeByNttId(nttId) + }.onFailure { throw it }.fold( + onSuccess = { + if (it != null) emit(it.toNoticeVO()) + else emit(null) + }, + onFailure = { it.printLog().also { emit(null) } } + ) + } + + override fun queryBookmarkByNttId(nttId: Int) = flow { + runCatching { + localDao.getBookmarkByNttId(nttId) + }.onFailure { throw it }.fold( + onSuccess = { + emit(it?.toBookmarkVO()) + }, + onFailure = { emit(null) } + ) + } + + override fun requestBookmarkDeletion(bookmark: BookmarkVO) = flow { + runCatching { + localDao.deleteBookmark(bookmark.toBookmark()) + }.onFailure { throw it }.fold( + onSuccess = { emit(true) }, + onFailure = { emit(false) } + ) + } + + override fun requestNoticeDeletion(notice: NoticeVO) = flow { + runCatching { + localDao.deleteNoticeEntity(notice.toNoticeEntity()) + }.onFailure { throw it }.fold( + onSuccess = { emit(true) }, + onFailure = { emit(false) } + ) + } + + + private fun Throwable.printLog() = + Log.d(TAG, "Failed to receive data\nREASON: ${this.stackTraceToString()}") + + private fun NoticeVO.toNoticeEntity() = + NoticeEntity( + nttId = this.nttId, + title = this.title, + url = this.url, + imageUrl = this.imageUrl ?: "", + departName = this.departName, + timestamp = this.timestamp + ) + + private fun NoticeEntity.toNoticeVO() = + NoticeVO( + entityId = this.noticeEntityId, + nttId = this.nttId, + title = this.title, + url = this.url, + imageUrl = this.imageUrl, + departName = this.departName, + timestamp = this.timestamp + ) + + private fun BookmarkVO.toBookmark() = + Bookmark( + bookmarkId = this.bookmarkId, + nttId = this.targetNoticeNttId, + isScheduled = this.isScheduled, + reminderSchedule = this.reminderSchedule, + note = this.bookmarkNote + ) + + private fun Bookmark.toBookmarkVO() = + BookmarkVO( + bookmarkId = this.bookmarkId, + targetNoticeNttId = this.nttId, + isScheduled = this.isScheduled, + reminderSchedule = this.reminderSchedule, + bookmarkNote = this.note + ) + + private fun List.toListOfBookmarkVO() = + this.map { it.toBookmarkVO() } +} \ No newline at end of file diff --git a/core/data/src/main/java/com/doyoonkim/data/repository/RemoteRepositoryImpl.kt b/core/data/src/main/java/com/doyoonkim/data/repository/RemoteRepositoryImpl.kt new file mode 100644 index 00000000..c7384868 --- /dev/null +++ b/core/data/src/main/java/com/doyoonkim/data/repository/RemoteRepositoryImpl.kt @@ -0,0 +1,152 @@ +package com.doyoonkim.data.repository + +import android.util.Log +import com.doyoonkim.domain.RemoteRepository +import com.doyoonkim.model.NoticeCategory +import com.doyoonkim.model.requestBody.DeviceTokenBody +import com.doyoonkim.model.requestBody.TopicSubscriptionPreferencesBody +import com.doyoonkim.model.requestBody.UserReportBody +import com.doyoonkim.network.KnuticeRemoteSource +import com.doyoonkim.network.model.DeviceTokenRequest +import com.doyoonkim.network.model.TopicSubscriptionPreferencesRequest +import com.doyoonkim.network.model.UserReportRequest +import kotlinx.coroutines.flow.flow +import model.NetworkResult +import javax.inject.Inject + +class RemoteRepositoryImpl @Inject constructor( + private val remoteSource: KnuticeRemoteSource +) : RemoteRepository { + private val TAG = "RemoteRepositoryImpl" + + override fun queryTopThreeNotices() = flow { + remoteSource.getTopThreeNotices().fold( + onSuccess = { + if (it.result?.resultCode == 200) emit(it.body?.toVO()) + else it.result.printLog().also { emit(null) } + }, + onFailure = { + it.printLog() + emit(null) + } + ) + } + + override fun queryNoticesPerPage(category: NoticeCategory, lastNttId: Int?) = flow { + remoteSource.getNoticesPerPage(category, lastNttId).fold( + onSuccess = { + if (it.result?.resultCode == 200) emit(it.body.map { it.toVO() }) + else it.result.printLog().also { emit(null) } + }, + onFailure = { + it.printLog() + emit(null) + } + ) + } + + override fun queryNoticeById(nttId: Int) = flow { + remoteSource.getNoticeById(nttId).fold( + onSuccess = { + if (it.result?.resultCode == 200) emit(it.body?.toVO()) + else it.result.printLog().also { emit(null) } + }, + onFailure = { + it.printLog() + emit(null) + } + ) + } + + override fun queryNoticesByKeyword(keyword: String) = flow { + remoteSource.getNoticesByKeyword(keyword).fold( + onSuccess = { + if (it.result?.resultCode == 200) emit(it.body.map { it.toVO() }) + else it.result.printLog().also { emit(null) } + }, + onFailure = { + it.printLog() + emit(null) + } + ) + } + + override fun queryTopicSubscriptionStatus() = flow { + remoteSource.getTopicSubscriptionStatus().fold( + onSuccess = { + if (it.result?.resultCode == 200) emit(it.body?.toVO()) + else { + if (it.body != null) emit(it.body?.toVO()) + else it.result.printLog().also { emit(null) } + } + }, + onFailure = { + it.printLog() + emit(null) + } + ) + } + + override fun requestTokenValidation(body: DeviceTokenBody) = flow { + remoteSource.validateToken( + DeviceTokenRequest(body = body) + ).fold( + onSuccess = { + if (it.result?.resultCode == 200) emit(true).also { + remoteSource.updateValidatedToken(body.fcmToken) + } + else it.result.printLog().also { emit(false) } + }, + onFailure = { + it.printLog() + emit(false) + } + ) + } + + override fun requestUpdateValidatedToken(fcmToken: String) { + remoteSource.updateValidatedToken(fcmToken) + } + + override fun requestUserReportSubmission(body: UserReportBody) = flow { + remoteSource.submitUserReport( + // Should be revised. + UserReportRequest(body = body.copy(fcmToken = remoteSource.validatedToken)) + ).fold( + onSuccess = { + if (it.result?.resultCode == 200) emit(true) + else it.result.printLog().also { emit(false) } + }, + onFailure = { + it.printLog() + emit(false) + } + ) + } + + override fun requestTopicSubscriptionPreferencesSubmission( + body: TopicSubscriptionPreferencesBody + ) = flow { + remoteSource.run { + submitTopicSubscriptionPreferences( + TopicSubscriptionPreferencesRequest(body = body.copy(fcmToken = this.validatedToken)) + ) + }.fold( + onSuccess = { + if (it.result?.resultCode == 200) emit(true) + else it.result.printLog().also { emit(false) } + }, + onFailure = { + it.printLog() + emit(false) + } + ) + } + + private fun Throwable.printLog() = + Log.d(TAG, "Failed to receive data\nREASON: ${this.stackTraceToString()}") + + private fun NetworkResult?.printLog() = + Log.d(TAG, "Failed to receive data (${this?.resultCode})" + + "\nREASON:${this?.resultMessage}") +} \ No newline at end of file diff --git a/core/data/src/main/java/com/doyoonkim/data/room/LocalRoomDatabase.kt b/core/data/src/main/java/com/doyoonkim/data/room/LocalRoomDatabase.kt new file mode 100644 index 00000000..6f28c99d --- /dev/null +++ b/core/data/src/main/java/com/doyoonkim/data/room/LocalRoomDatabase.kt @@ -0,0 +1,13 @@ +package com.doyoonkim.data.room + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.doyoonkim.data.model.Bookmark +import com.doyoonkim.data.model.NoticeEntity + +// Migration Strategy: https://stackoverflow.com/questions/56478785/room-database-schema-update-without-data-loss + +@Database(entities = [Bookmark::class, NoticeEntity::class], version = 1) +abstract class LocalDatabase : RoomDatabase() { + abstract fun getDao(): MainDatabaseDao +} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/data/local/MainDatabaseDao.kt b/core/data/src/main/java/com/doyoonkim/data/room/MainDatabaseDao.kt similarity index 81% rename from app/src/main/java/com/doyoonkim/knutice/data/local/MainDatabaseDao.kt rename to core/data/src/main/java/com/doyoonkim/data/room/MainDatabaseDao.kt index c8606812..f8dc9dd2 100644 --- a/app/src/main/java/com/doyoonkim/knutice/data/local/MainDatabaseDao.kt +++ b/core/data/src/main/java/com/doyoonkim/data/room/MainDatabaseDao.kt @@ -1,4 +1,4 @@ -package com.doyoonkim.knutice.data.local +package com.doyoonkim.data.room import androidx.room.Dao import androidx.room.Delete @@ -6,8 +6,8 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update -import com.doyoonkim.knutice.model.Bookmark -import com.doyoonkim.knutice.model.NoticeEntity +import com.doyoonkim.data.model.Bookmark +import com.doyoonkim.data.model.NoticeEntity @Dao interface MainDatabaseDao { @@ -30,7 +30,7 @@ interface MainDatabaseDao { fun deleteNoticeEntity(target: NoticeEntity) @Query("SELECT * FROM NoticeEntity WHERE ntt_id=:nttId") - fun getNoticeByNttId(nttId: Int): NoticeEntity + fun getNoticeByNttId(nttId: Int): NoticeEntity? @Query("SELECT * FROM Bookmark WHERE target_ntt_id=:nttId") fun getBookmarkByNttId(nttId: Int): Bookmark? diff --git a/core/data/src/test/java/com/doyoonkim/data/ExampleUnitTest.kt b/core/data/src/test/java/com/doyoonkim/data/ExampleUnitTest.kt new file mode 100644 index 00000000..24412861 --- /dev/null +++ b/core/data/src/test/java/com/doyoonkim/data/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.doyoonkim.data + +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/domain/.gitignore b/core/domain/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/domain/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts new file mode 100644 index 00000000..8fa67eda --- /dev/null +++ b/core/domain/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + id("java-library") + alias(libs.plugins.jetbrains.kotlin.jvm) + id("kotlin-kapt") +} +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + } +} + +dependencies { + implementation(projects.core.model) + + implementation(libs.kotlinx.coroutines.core) + + // Dagger + implementation(libs.dagger) + kapt(libs.dagger.compiler) +} diff --git a/core/domain/src/main/java/com/doyoonkim/domain/ImageRepository.kt b/core/domain/src/main/java/com/doyoonkim/domain/ImageRepository.kt new file mode 100644 index 00000000..c79660ac --- /dev/null +++ b/core/domain/src/main/java/com/doyoonkim/domain/ImageRepository.kt @@ -0,0 +1,11 @@ +package com.doyoonkim.domain + +import kotlinx.coroutines.flow.Flow + +/** + * @author kimdoyoon + * Created 6/17/25 at 12:40 AM + */ +interface ImageRepository { + suspend fun getImageByteArrayFromUrl(url: String): ByteArray? +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/doyoonkim/domain/LocalRepository.kt b/core/domain/src/main/java/com/doyoonkim/domain/LocalRepository.kt new file mode 100644 index 00000000..38f9d6b6 --- /dev/null +++ b/core/domain/src/main/java/com/doyoonkim/domain/LocalRepository.kt @@ -0,0 +1,24 @@ +package com.doyoonkim.domain + +import com.doyoonkim.model.BookmarkVO +import com.doyoonkim.model.NoticeVO +import kotlinx.coroutines.flow.Flow + +// Dependency Inversion +interface LocalRepository { + fun createBookmark(bookmark: BookmarkVO): Flow + + fun createBookmark(bookmark: BookmarkVO, targetNotice: NoticeVO): Flow + + fun updateBookmark(bookmark: BookmarkVO): Flow + + fun queryAllBookmarks(): Flow + + fun queryNoticeById(nttId: Int): Flow + + fun queryBookmarkByNttId(nttId: Int): Flow + + fun requestBookmarkDeletion(bookmark: BookmarkVO): Flow + + fun requestNoticeDeletion(notice: NoticeVO): Flow +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/doyoonkim/domain/RemoteRepository.kt b/core/domain/src/main/java/com/doyoonkim/domain/RemoteRepository.kt new file mode 100644 index 00000000..37b72a7d --- /dev/null +++ b/core/domain/src/main/java/com/doyoonkim/domain/RemoteRepository.kt @@ -0,0 +1,34 @@ +package com.doyoonkim.domain + +import com.doyoonkim.model.NoticeCategory +import com.doyoonkim.model.NoticeVO +import com.doyoonkim.model.TopThreeNoticeVO +import com.doyoonkim.model.TopicSubscriptionPreferencesVO +import com.doyoonkim.model.requestBody.DeviceTokenBody +import com.doyoonkim.model.requestBody.TopicSubscriptionPreferencesBody +import com.doyoonkim.model.requestBody.UserReportBody +import kotlinx.coroutines.flow.Flow + +// Dependency Inversion +interface RemoteRepository { + fun queryTopThreeNotices(): Flow + + fun queryNoticesPerPage(category: NoticeCategory, lastNttId: Int?): Flow?> + + fun queryNoticeById(nttId: Int): Flow + + fun queryNoticesByKeyword(keyword: String): Flow?> + + fun queryTopicSubscriptionStatus(): Flow + + fun requestTokenValidation(body: DeviceTokenBody): Flow + + fun requestUpdateValidatedToken(fcmToken: String) + + fun requestUserReportSubmission(body: UserReportBody): Flow + + fun requestTopicSubscriptionPreferencesSubmission( + body: TopicSubscriptionPreferencesBody + ): Flow + +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/doyoonkim/domain/di/DomainModule.kt b/core/domain/src/main/java/com/doyoonkim/domain/di/DomainModule.kt new file mode 100644 index 00000000..78eddcae --- /dev/null +++ b/core/domain/src/main/java/com/doyoonkim/domain/di/DomainModule.kt @@ -0,0 +1,86 @@ +package com.doyoonkim.domain.di + +import com.doyoonkim.domain.usecases.FetchAllBookmarks +import com.doyoonkim.domain.usecases.FetchAllBookmarksImpl +import com.doyoonkim.domain.usecases.FetchNoticeById +import com.doyoonkim.domain.usecases.FetchNoticeByIdFromLocal +import com.doyoonkim.domain.usecases.FetchNoticeByIdFromLocalImpl +import com.doyoonkim.domain.usecases.FetchNoticeByIdImpl +import com.doyoonkim.domain.usecases.FetchNoticesByKeyword +import com.doyoonkim.domain.usecases.FetchNoticesByKeywordImpl +import com.doyoonkim.domain.usecases.FetchNoticesPerPage +import com.doyoonkim.domain.usecases.FetchNoticesPerPageImpl +import com.doyoonkim.domain.usecases.FetchTopThreeNotices +import com.doyoonkim.domain.usecases.FetchTopThreeNoticesImpl +import com.doyoonkim.domain.usecases.FetchTopicSubscriptionStatus +import com.doyoonkim.domain.usecases.FetchTopicSubscriptionStatusImpl +import com.doyoonkim.domain.usecases.ModifyBookmark +import com.doyoonkim.domain.usecases.ModifyBookmarkImpl +import com.doyoonkim.domain.usecases.SubmitNotificationPreferences +import com.doyoonkim.domain.usecases.SubmitNotificationPreferencesImpl +import com.doyoonkim.domain.usecases.SubmitUserReport +import com.doyoonkim.domain.usecases.SubmitUserReportImpl +import com.doyoonkim.domain.usecases.ValidateDeviceToken +import com.doyoonkim.domain.usecases.ValidateDeviceTokenImpl +import dagger.Binds +import dagger.Module + +@Module +abstract class DomainModule { + + @Binds + abstract fun bindFetchAllBookmarks( + impl: FetchAllBookmarksImpl + ): FetchAllBookmarks + + @Binds + abstract fun bindsFetchNoticeById( + impl: FetchNoticeByIdImpl + ): FetchNoticeById + + @Binds + abstract fun bindsFetchNoticeByIdFromLocal( + impl: FetchNoticeByIdFromLocalImpl + ): FetchNoticeByIdFromLocal + + @Binds + abstract fun bindsFetchNoticesByKeyword( + impl: FetchNoticesByKeywordImpl + ): FetchNoticesByKeyword + + @Binds + abstract fun bindsFetchNoticesPerPage( + impl: FetchNoticesPerPageImpl + ): FetchNoticesPerPage + + @Binds + abstract fun bindsFetchTopicSubscriptionStatus( + impl: FetchTopicSubscriptionStatusImpl + ): FetchTopicSubscriptionStatus + + @Binds + abstract fun bindsFetchTopThreeNotices( + impl: FetchTopThreeNoticesImpl + ): FetchTopThreeNotices + + @Binds + abstract fun bindsModifyBookmark( + impl: ModifyBookmarkImpl + ): ModifyBookmark + + @Binds + abstract fun bindsSubmitNotificationPReferences( + impl: SubmitNotificationPreferencesImpl + ): SubmitNotificationPreferences + + @Binds + abstract fun bindsSubmitUserReport( + impl: SubmitUserReportImpl + ): SubmitUserReport + + @Binds + abstract fun bindsValidateDeviceToken( + impl: ValidateDeviceTokenImpl + ): ValidateDeviceToken + +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/doyoonkim/domain/usecases/FetchAllBookmarks.kt b/core/domain/src/main/java/com/doyoonkim/domain/usecases/FetchAllBookmarks.kt new file mode 100644 index 00000000..730e6656 --- /dev/null +++ b/core/domain/src/main/java/com/doyoonkim/domain/usecases/FetchAllBookmarks.kt @@ -0,0 +1,29 @@ +package com.doyoonkim.domain.usecases + +import com.doyoonkim.domain.LocalRepository +import com.doyoonkim.model.BookmarkVO +import com.doyoonkim.model.NoticeVO +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.transform +import javax.inject.Inject + + +interface FetchAllBookmarks { + operator fun invoke(): Flow> +} + +class FetchAllBookmarksImpl @Inject constructor( + private val localRepository: LocalRepository +) : FetchAllBookmarks { + + override operator fun invoke(): Flow> = + localRepository.queryAllBookmarks().transform { bookmarkVO -> + bookmarkVO?.let { + emitAll(localRepository.queryNoticeById(it.targetNoticeNttId).transform { nullable -> + nullable?.let { vo-> emit(Pair(bookmarkVO, vo)) } + }) + } + } + +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/doyoonkim/domain/usecases/FetchNoticeById.kt b/core/domain/src/main/java/com/doyoonkim/domain/usecases/FetchNoticeById.kt new file mode 100644 index 00000000..39173253 --- /dev/null +++ b/core/domain/src/main/java/com/doyoonkim/domain/usecases/FetchNoticeById.kt @@ -0,0 +1,21 @@ +package com.doyoonkim.domain.usecases + +import com.doyoonkim.domain.RemoteRepository +import com.doyoonkim.model.NoticeVO +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.transform +import javax.inject.Inject + +interface FetchNoticeById { + operator fun invoke(nttId: Int): Flow +} + +class FetchNoticeByIdImpl @Inject constructor( + private val remoteRepository: RemoteRepository +) : FetchNoticeById { + + override operator fun invoke(nttId: Int) = + remoteRepository.queryNoticeById(nttId).transform { result -> + result?.let { emit(it) } + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/doyoonkim/domain/usecases/FetchNoticeByIdFromLocal.kt b/core/domain/src/main/java/com/doyoonkim/domain/usecases/FetchNoticeByIdFromLocal.kt new file mode 100644 index 00000000..cb9a1998 --- /dev/null +++ b/core/domain/src/main/java/com/doyoonkim/domain/usecases/FetchNoticeByIdFromLocal.kt @@ -0,0 +1,22 @@ +package com.doyoonkim.domain.usecases + +import com.doyoonkim.domain.LocalRepository +import com.doyoonkim.model.NoticeVO +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.transform +import javax.inject.Inject + +interface FetchNoticeByIdFromLocal { + operator fun invoke(nttId: Int): Flow +} + +class FetchNoticeByIdFromLocalImpl @Inject constructor( + private val localRepository: LocalRepository +) : FetchNoticeByIdFromLocal { + + override operator fun invoke(nttId: Int) = + localRepository.queryNoticeById(nttId).transform { result -> + result?.let { emit(it) } + } + +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/doyoonkim/domain/usecases/FetchNoticesByKeyword.kt b/core/domain/src/main/java/com/doyoonkim/domain/usecases/FetchNoticesByKeyword.kt new file mode 100644 index 00000000..8f9b36bb --- /dev/null +++ b/core/domain/src/main/java/com/doyoonkim/domain/usecases/FetchNoticesByKeyword.kt @@ -0,0 +1,21 @@ +package com.doyoonkim.domain.usecases + +import com.doyoonkim.domain.RemoteRepository +import com.doyoonkim.model.NoticeVO +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.transform +import javax.inject.Inject + +interface FetchNoticesByKeyword { + operator fun invoke (keyword: String): Flow> +} + +class FetchNoticesByKeywordImpl @Inject constructor( + private val remoteRepository: RemoteRepository +) : FetchNoticesByKeyword { + + override operator fun invoke(keyword: String) = + remoteRepository.queryNoticesByKeyword(keyword).transform { result -> + result?.let { emit(it) } + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/doyoonkim/domain/usecases/FetchNoticesPerPage.kt b/core/domain/src/main/java/com/doyoonkim/domain/usecases/FetchNoticesPerPage.kt new file mode 100644 index 00000000..511683c2 --- /dev/null +++ b/core/domain/src/main/java/com/doyoonkim/domain/usecases/FetchNoticesPerPage.kt @@ -0,0 +1,25 @@ +package com.doyoonkim.domain.usecases + +import com.doyoonkim.domain.RemoteRepository +import com.doyoonkim.model.NoticeCategory +import com.doyoonkim.model.NoticeVO +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.transform +import javax.inject.Inject + +interface FetchNoticesPerPage { + operator fun invoke(category: NoticeCategory, lastNttId: Int): Flow> +} + +class FetchNoticesPerPageImpl @Inject constructor( + private val remoteRepository: RemoteRepository +) : FetchNoticesPerPage { + + override operator fun invoke(category: NoticeCategory, lastNttId: Int) = + remoteRepository.run { + if (lastNttId == 0) queryNoticesPerPage(category, null) + else queryNoticesPerPage(category, lastNttId) + }.transform { result -> + result?.let { emit(it) } + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/doyoonkim/domain/usecases/FetchTopThreeNotices.kt b/core/domain/src/main/java/com/doyoonkim/domain/usecases/FetchTopThreeNotices.kt new file mode 100644 index 00000000..696d6ab0 --- /dev/null +++ b/core/domain/src/main/java/com/doyoonkim/domain/usecases/FetchTopThreeNotices.kt @@ -0,0 +1,22 @@ +package com.doyoonkim.domain.usecases + +import com.doyoonkim.domain.RemoteRepository +import com.doyoonkim.model.TopThreeNoticeVO +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.transform +import javax.inject.Inject + +interface FetchTopThreeNotices { + operator fun invoke(): Flow +} + +class FetchTopThreeNoticesImpl @Inject constructor( + private val remoteRepository: RemoteRepository +) : FetchTopThreeNotices { + + override operator fun invoke(): Flow = + remoteRepository.queryTopThreeNotices().transform { result -> + result?.let { emit(it) } + } + +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/doyoonkim/domain/usecases/FetchTopicSubscriptionStatus.kt b/core/domain/src/main/java/com/doyoonkim/domain/usecases/FetchTopicSubscriptionStatus.kt new file mode 100644 index 00000000..8f226ddd --- /dev/null +++ b/core/domain/src/main/java/com/doyoonkim/domain/usecases/FetchTopicSubscriptionStatus.kt @@ -0,0 +1,22 @@ +package com.doyoonkim.domain.usecases + +import com.doyoonkim.domain.RemoteRepository +import com.doyoonkim.model.TopicSubscriptionPreferencesVO +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.transform +import javax.inject.Inject + +interface FetchTopicSubscriptionStatus { + operator fun invoke(): Flow +} + +class FetchTopicSubscriptionStatusImpl @Inject constructor( + private val remoteRepository: RemoteRepository +) : FetchTopicSubscriptionStatus { + + override operator fun invoke() = + remoteRepository.queryTopicSubscriptionStatus().transform { nullable -> + nullable?.let { emit(it) } + } + +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/doyoonkim/domain/usecases/ModifyBookmark.kt b/core/domain/src/main/java/com/doyoonkim/domain/usecases/ModifyBookmark.kt new file mode 100644 index 00000000..37089257 --- /dev/null +++ b/core/domain/src/main/java/com/doyoonkim/domain/usecases/ModifyBookmark.kt @@ -0,0 +1,51 @@ +package com.doyoonkim.domain.usecases + +import com.doyoonkim.domain.LocalRepository +import com.doyoonkim.domain.RemoteRepository +import com.doyoonkim.model.BookmarkVO +import com.doyoonkim.model.NoticeVO +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.transform +import javax.inject.Inject + +interface ModifyBookmark { + // Contains function for query, update, delete of Bookmark + fun query(nttId: Int): Flow + + fun createOrUpdate(bookmark: BookmarkVO, notice: NoticeVO?): Flow + + fun delete(bookmark: BookmarkVO, notice: NoticeVO): Flow +} + +class ModifyBookmarkImpl @Inject constructor( + private val localRepository: LocalRepository, + private val remoteRepository: RemoteRepository +) : ModifyBookmark { + + override fun query(nttId: Int): Flow = + localRepository.queryBookmarkByNttId(nttId).transform { result -> + result?.let { emit(it) } + } + + // TODO: Need to be revised later. (Is NoticeVO really required) + override fun createOrUpdate(bookmark: BookmarkVO, notice: NoticeVO?): Flow { + return if (notice == null) { + // Need creation. Request notice instance from the remote source first. + remoteRepository.queryNoticeById(bookmark.targetNoticeNttId).transform { result -> + if (result != null) emitAll(localRepository.createBookmark(bookmark, result)) + else emit(false) + } + } else { + localRepository.updateBookmark(bookmark) + } + } + + override fun delete(bookmark: BookmarkVO, notice: NoticeVO): Flow = + localRepository.requestBookmarkDeletion(bookmark).transform { result -> + if (result) emitAll(localRepository.requestNoticeDeletion(notice)) + else emit(false) + } + +} + diff --git a/core/domain/src/main/java/com/doyoonkim/domain/usecases/SubmitNotificationPreferences.kt b/core/domain/src/main/java/com/doyoonkim/domain/usecases/SubmitNotificationPreferences.kt new file mode 100644 index 00000000..1d891f3e --- /dev/null +++ b/core/domain/src/main/java/com/doyoonkim/domain/usecases/SubmitNotificationPreferences.kt @@ -0,0 +1,18 @@ +package com.doyoonkim.domain.usecases + +import com.doyoonkim.domain.RemoteRepository +import com.doyoonkim.model.requestBody.TopicSubscriptionPreferencesBody +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +interface SubmitNotificationPreferences { + operator fun invoke(body: TopicSubscriptionPreferencesBody): Flow +} + +class SubmitNotificationPreferencesImpl @Inject constructor( + private val remoteRepository: RemoteRepository +) : SubmitNotificationPreferences { + + override operator fun invoke(body: TopicSubscriptionPreferencesBody) = + remoteRepository.requestTopicSubscriptionPreferencesSubmission(body) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/doyoonkim/domain/usecases/SubmitUserReport.kt b/core/domain/src/main/java/com/doyoonkim/domain/usecases/SubmitUserReport.kt new file mode 100644 index 00000000..6099c0f8 --- /dev/null +++ b/core/domain/src/main/java/com/doyoonkim/domain/usecases/SubmitUserReport.kt @@ -0,0 +1,19 @@ +package com.doyoonkim.domain.usecases + +import com.doyoonkim.domain.RemoteRepository +import com.doyoonkim.model.requestBody.UserReportBody +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +interface SubmitUserReport { + operator fun invoke(body: UserReportBody): Flow +} + +class SubmitUserReportImpl @Inject constructor( + private val remoteRepository: RemoteRepository +) : SubmitUserReport { + + override operator fun invoke(body: UserReportBody) = + remoteRepository.requestUserReportSubmission(body) + +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/doyoonkim/domain/usecases/ValidateDeviceToken.kt b/core/domain/src/main/java/com/doyoonkim/domain/usecases/ValidateDeviceToken.kt new file mode 100644 index 00000000..3c9f694c --- /dev/null +++ b/core/domain/src/main/java/com/doyoonkim/domain/usecases/ValidateDeviceToken.kt @@ -0,0 +1,19 @@ +package com.doyoonkim.domain.usecases + +import com.doyoonkim.domain.RemoteRepository +import com.doyoonkim.model.requestBody.DeviceTokenBody +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + + +interface ValidateDeviceToken { + operator fun invoke(requestBody: DeviceTokenBody): Flow +} + +class ValidateDeviceTokenImpl @Inject constructor( + private val remoteRepository: RemoteRepository +) : ValidateDeviceToken { + + override operator fun invoke(requestBody: DeviceTokenBody) = + remoteRepository.requestTokenValidation(requestBody) +} \ No newline at end of file diff --git a/core/model/.gitignore b/core/model/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/model/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts new file mode 100644 index 00000000..864aec72 --- /dev/null +++ b/core/model/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id("java-library") + alias(libs.plugins.jetbrains.kotlin.jvm) +} +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + } +} + +dependencies { + implementation(libs.kotlin.serialization) +} diff --git a/core/model/src/main/java/com/doyoonkim/model/BookmarkVO.kt b/core/model/src/main/java/com/doyoonkim/model/BookmarkVO.kt new file mode 100644 index 00000000..b98988fb --- /dev/null +++ b/core/model/src/main/java/com/doyoonkim/model/BookmarkVO.kt @@ -0,0 +1,10 @@ +package com.doyoonkim.model + +data class BookmarkVO( + val bookmarkId: Int = 0, + // nttId is being used as ID for local Push Notification as well. + val targetNoticeNttId: Int, + val isScheduled: Boolean, + val reminderSchedule: Long, + val bookmarkNote: String +) diff --git a/core/model/src/main/java/com/doyoonkim/model/NoticeCatetory.kt b/core/model/src/main/java/com/doyoonkim/model/NoticeCatetory.kt new file mode 100644 index 00000000..68677000 --- /dev/null +++ b/core/model/src/main/java/com/doyoonkim/model/NoticeCatetory.kt @@ -0,0 +1,14 @@ +package com.doyoonkim.model + +/** + * @author kimdoyoon + * Created 6/2/25 at 11:20 PM + */ +enum class NoticeCategory { + GENERAL_NEWS, + ACADEMIC_NEWS, + SCHOLARSHIP_NEWS, + EVENT_NEWS, + JOB_NEWS, + Unspecified +} \ No newline at end of file diff --git a/core/model/src/main/java/com/doyoonkim/model/NoticeVO.kt b/core/model/src/main/java/com/doyoonkim/model/NoticeVO.kt new file mode 100644 index 00000000..16875e94 --- /dev/null +++ b/core/model/src/main/java/com/doyoonkim/model/NoticeVO.kt @@ -0,0 +1,11 @@ +package com.doyoonkim.model + +data class NoticeVO( + val entityId: Int? = null, // Later migrated and removed. + val nttId: Int = -1, + val title: String = "", + val url: String = "", + val imageUrl: String? = null, + val departName: String = "", + val timestamp: String = "" +) \ No newline at end of file diff --git a/core/model/src/main/java/com/doyoonkim/model/TopThreeNoticeVO.kt b/core/model/src/main/java/com/doyoonkim/model/TopThreeNoticeVO.kt new file mode 100644 index 00000000..75404a93 --- /dev/null +++ b/core/model/src/main/java/com/doyoonkim/model/TopThreeNoticeVO.kt @@ -0,0 +1,8 @@ +package com.doyoonkim.model + +data class TopThreeNoticeVO( + val general: List = listOf(), + val scholarship: List = listOf(), + val event: List = listOf(), + val academic: List = listOf() +) diff --git a/core/model/src/main/java/com/doyoonkim/model/TopicSubscriptionPreferencesVO.kt b/core/model/src/main/java/com/doyoonkim/model/TopicSubscriptionPreferencesVO.kt new file mode 100644 index 00000000..66231fe1 --- /dev/null +++ b/core/model/src/main/java/com/doyoonkim/model/TopicSubscriptionPreferencesVO.kt @@ -0,0 +1,9 @@ +package com.doyoonkim.model + +data class TopicSubscriptionPreferencesVO( + val general: Boolean = false, + val scholarship: Boolean = false, + val event: Boolean = false, + val academic: Boolean = false, + val employment: Boolean = false +) diff --git a/core/model/src/main/java/com/doyoonkim/model/requestBody/DeviceTokenBody.kt b/core/model/src/main/java/com/doyoonkim/model/requestBody/DeviceTokenBody.kt new file mode 100644 index 00000000..3563f060 --- /dev/null +++ b/core/model/src/main/java/com/doyoonkim/model/requestBody/DeviceTokenBody.kt @@ -0,0 +1,5 @@ +package com.doyoonkim.model.requestBody + +data class DeviceTokenBody( + val fcmToken: String +) diff --git a/core/model/src/main/java/com/doyoonkim/model/requestBody/TopicSubscriptionPreferencesBody.kt b/core/model/src/main/java/com/doyoonkim/model/requestBody/TopicSubscriptionPreferencesBody.kt new file mode 100644 index 00000000..fad0dff0 --- /dev/null +++ b/core/model/src/main/java/com/doyoonkim/model/requestBody/TopicSubscriptionPreferencesBody.kt @@ -0,0 +1,7 @@ +package com.doyoonkim.model.requestBody + +data class TopicSubscriptionPreferencesBody( + val fcmToken: String? = null, + val noticeName: String = "Unspecified", + val isSubscribed: Boolean = false +) diff --git a/core/model/src/main/java/com/doyoonkim/model/requestBody/UserReportBody.kt b/core/model/src/main/java/com/doyoonkim/model/requestBody/UserReportBody.kt new file mode 100644 index 00000000..3aaf196d --- /dev/null +++ b/core/model/src/main/java/com/doyoonkim/model/requestBody/UserReportBody.kt @@ -0,0 +1,9 @@ +package com.doyoonkim.model.requestBody + +data class UserReportBody( + val fcmToken: String? = null, + val content: String = "", + val clientType: String = "APP", + val deviceName: String = "Android Device", + val version: String = "Unspecified" +) diff --git a/core/network/.gitignore b/core/network/.gitignore new file mode 100644 index 00000000..c0a79076 --- /dev/null +++ b/core/network/.gitignore @@ -0,0 +1,4 @@ +/build + +# Local Properties +local.properties diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts new file mode 100644 index 00000000..8936b327 --- /dev/null +++ b/core/network/build.gradle.kts @@ -0,0 +1,73 @@ +import java.io.FileInputStream +import java.util.Properties + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + id("kotlin-kapt") +} + +android { + namespace = "com.doyoonkim.network" + compileSdk = 35 + + val properties = Properties().apply { + load(FileInputStream("${rootDir}/local.properties")) + } + val apiBaseLive = properties["api_migrated"] ?: "" + + defaultConfig { + minSdk = 30 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + + buildConfigField("String", "API_LIVE", "\"$apiBaseLive\"") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + buildConfig = true + } +} + +dependencies { + implementation(projects.core.model) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + + // Coroutine for Android + implementation(libs.kotlinx.coroutines.android) + + // Retrofit 2 + implementation(libs.retrofit) + implementation(libs.converter.gson) + + // Dagger + implementation(libs.dagger) + implementation(libs.dagger.android) + implementation(libs.dagger.android.support) + kapt(libs.dagger.compiler) + kapt(libs.dagger.android.processor) +} \ No newline at end of file diff --git a/core/network/consumer-rules.pro b/core/network/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/core/network/proguard-rules.pro b/core/network/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/core/network/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/network/src/androidTest/java/com/doyoonkim/network/ExampleInstrumentedTest.kt b/core/network/src/androidTest/java/com/doyoonkim/network/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..62ff60bf --- /dev/null +++ b/core/network/src/androidTest/java/com/doyoonkim/network/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.doyoonkim.network + +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("com.doyoonkim.network.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/core/network/src/main/AndroidManifest.xml b/core/network/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/core/network/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/core/network/src/main/kotlin/com/doyoonkim/network/ImageRemoteSource.kt b/core/network/src/main/kotlin/com/doyoonkim/network/ImageRemoteSource.kt new file mode 100644 index 00000000..c0158270 --- /dev/null +++ b/core/network/src/main/kotlin/com/doyoonkim/network/ImageRemoteSource.kt @@ -0,0 +1,17 @@ +package com.doyoonkim.network + +import java.net.URL +import javax.inject.Inject + +/** + * @author kimdoyoon + * Created 6/17/25 at 12:42 AM + */ +class ImageRemoteSource @Inject constructor() { + suspend fun getByteArrayFromImageUrl(url: String): Result { + return runCatching { + URL(url).readBytes() + }.onFailure { throw it } + } + +} \ No newline at end of file diff --git a/core/network/src/main/kotlin/com/doyoonkim/network/KnuticeRemoteSource.kt b/core/network/src/main/kotlin/com/doyoonkim/network/KnuticeRemoteSource.kt new file mode 100644 index 00000000..84eb8092 --- /dev/null +++ b/core/network/src/main/kotlin/com/doyoonkim/network/KnuticeRemoteSource.kt @@ -0,0 +1,72 @@ +package com.doyoonkim.network + +import com.doyoonkim.model.NoticeCategory +import com.doyoonkim.network.model.DeviceTokenRequest +import com.doyoonkim.network.model.TopicSubscriptionPreferencesRequest +import com.doyoonkim.network.model.UserReportRequest +import com.doyoonkim.network.retrofit.KnuticeService +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import javax.inject.Inject +import javax.inject.Singleton + +/** + * @author kimdoyoon + * Created 6/3/25 at 12:27 AM + */ + +// This class should be provided/injected as Singleton Instance. +@Singleton +class KnuticeRemoteSource @Inject constructor() { + private val TAG = "KnuticeRemoteSource" + + // Consider providing this instance via DI. + private val knuticeService = Retrofit.Builder() + .baseUrl(BuildConfig.API_LIVE) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + // Should be later migrated to DataStore. + var validatedToken: String = "" + + suspend fun getTopThreeNotices() = runCatching { + knuticeService.create(KnuticeService::class.java).getTopThreeNotices() + }.onFailure { throw it } + + suspend fun getNoticesPerPage(category: NoticeCategory, lastNttId: Int?) = runCatching { + knuticeService.create(KnuticeService::class.java).getNoticesPerPage(category, lastNttId) + }.onFailure { throw it } + + suspend fun getNoticeById(nttId: Int) = runCatching { + knuticeService.create(KnuticeService::class.java).getNoticeById(nttId.toString()) + }.onFailure { throw it } + + + suspend fun getNoticesByKeyword(keyword: String) = runCatching { + knuticeService.create(KnuticeService::class.java).getNoticesByKeyword(keyword) + }.onFailure { throw it } + + suspend fun getTopicSubscriptionStatus() = runCatching { + if (validatedToken.isBlank()) throw Exception("No validated token found") + else { + knuticeService.create(KnuticeService::class.java).getTopicSubscriptionStatus(validatedToken) + } + }.onFailure { throw it } + + suspend fun validateToken(request: DeviceTokenRequest) = runCatching { + knuticeService.create(KnuticeService::class.java).validateToken(request) + }.onFailure { throw it } + + fun updateValidatedToken(fcmToken: String) { + validatedToken = fcmToken + } + + suspend fun submitUserReport(request: UserReportRequest) = runCatching { + knuticeService.create(KnuticeService::class.java).submitUserReport(request) + }.onFailure { throw it } + + suspend fun submitTopicSubscriptionPreferences(request: TopicSubscriptionPreferencesRequest) = + runCatching { + knuticeService.create(KnuticeService::class.java).submitTopicSubscriptionPreferences(request) + }.onFailure { throw it } +} \ No newline at end of file diff --git a/core/network/src/main/kotlin/com/doyoonkim/network/model/Requests.kt b/core/network/src/main/kotlin/com/doyoonkim/network/model/Requests.kt new file mode 100644 index 00000000..3edcd103 --- /dev/null +++ b/core/network/src/main/kotlin/com/doyoonkim/network/model/Requests.kt @@ -0,0 +1,25 @@ +package com.doyoonkim.network.model + +import com.doyoonkim.model.requestBody.DeviceTokenBody +import com.doyoonkim.model.requestBody.TopicSubscriptionPreferencesBody +import com.doyoonkim.model.requestBody.UserReportBody +import model.NetworkResult + +/** + * @author kimdoyoon + * Created 6/3/25 at 12:10 AM + */ +data class DeviceTokenRequest( + val result: NetworkResult? = NetworkResult(), + val body: DeviceTokenBody +) + +data class UserReportRequest( + val result: NetworkResult? = NetworkResult(), + val body: UserReportBody +) + +data class TopicSubscriptionPreferencesRequest( + val result: NetworkResult? = NetworkResult(), + val body: TopicSubscriptionPreferencesBody +) \ No newline at end of file diff --git a/core/network/src/main/kotlin/com/doyoonkim/network/model/Responses.kt b/core/network/src/main/kotlin/com/doyoonkim/network/model/Responses.kt new file mode 100644 index 00000000..b14967d4 --- /dev/null +++ b/core/network/src/main/kotlin/com/doyoonkim/network/model/Responses.kt @@ -0,0 +1,46 @@ +package model + +import com.doyoonkim.network.model.dto.TopicSubscriptionPreferencesDTO +import com.google.gson.annotations.SerializedName +import model.dto.NoticeDTO +import model.dto.TopThreeNoticeDTO + +/** + * @author kimdoyoon + * Created 6/2/25 at 11:04 PM + */ +data class NetworkResult( + @SerializedName("resultCode") var resultCode: Int? = null, + @SerializedName("resultMessage") var resultMessage: String? = null, + @SerializedName("resultDescription") var resultDescription: String? = null +) + +data class TopThreeNoticeResults( + @SerializedName("result") var result: NetworkResult? = NetworkResult(), + @SerializedName("body") var body: TopThreeNoticeDTO? = TopThreeNoticeDTO() +) + +data class NoticesPerPageResult( + @SerializedName("result") var result: NetworkResult? = NetworkResult(), + @SerializedName("body") var body: ArrayList +) + +data class NoticeByIdResult( + @SerializedName("result") var result: NetworkResult? = NetworkResult(), + @SerializedName("body") var body: NoticeDTO? = NoticeDTO() +) + +data class NoticesByKeywordResult( + @SerializedName("result") var result: NetworkResult? = NetworkResult(), + @SerializedName("body") var body: ArrayList +) + +data class TopicSubscriptionPreferencesResult( + @SerializedName("reuslt") var result: NetworkResult? = NetworkResult(), + @SerializedName("body") var body: TopicSubscriptionPreferencesDTO? = TopicSubscriptionPreferencesDTO() +) + +data class PostResult( + @SerializedName("result") var result: NetworkResult? = NetworkResult(), + @SerializedName("body") var body: Boolean? = null +) diff --git a/core/network/src/main/kotlin/com/doyoonkim/network/model/dto/NoticeDTO.kt b/core/network/src/main/kotlin/com/doyoonkim/network/model/dto/NoticeDTO.kt new file mode 100644 index 00000000..59b0314e --- /dev/null +++ b/core/network/src/main/kotlin/com/doyoonkim/network/model/dto/NoticeDTO.kt @@ -0,0 +1,28 @@ +package model.dto + +import com.doyoonkim.model.NoticeVO +import com.google.gson.annotations.SerializedName + +/** + * @author kimdoyoon + * Created 6/2/25 at 11:08 PM + */ +data class NoticeDTO( + @SerializedName("nttId") var nttId: Int? = null, + @SerializedName("title") var title: String? = null, + @SerializedName("contentUrl") var contentUrl: String? = null, + @SerializedName("contentImage") var contentImage: String? = null, + @SerializedName("departmentName") var departName: String? = null, + @SerializedName("registeredAt") var registeredAt: String? = null, + @SerializedName("noticeName") var noticeCategory: String? = null +) { + fun toVO() = + NoticeVO( + nttId = this.nttId ?: -1, + title = this.title ?: "", + url = this.contentUrl ?: "", + imageUrl = this.contentImage, + departName = this.departName ?: "", + timestamp = this.registeredAt ?: "" + ) +} diff --git a/core/network/src/main/kotlin/com/doyoonkim/network/model/dto/TopThreeNoticeDTO.kt b/core/network/src/main/kotlin/com/doyoonkim/network/model/dto/TopThreeNoticeDTO.kt new file mode 100644 index 00000000..e2678d4e --- /dev/null +++ b/core/network/src/main/kotlin/com/doyoonkim/network/model/dto/TopThreeNoticeDTO.kt @@ -0,0 +1,23 @@ +package model.dto + +import com.doyoonkim.model.TopThreeNoticeVO +import com.google.gson.annotations.SerializedName + +/** + * @author kimdoyoon + * Created 6/2/25 at 11:01 PM + */ +data class TopThreeNoticeDTO( + @SerializedName("latestThreeGeneralNews") var generalNotices: ArrayList = arrayListOf(), + @SerializedName("latestThreeScholarshipNews") var scholarshipNotices: ArrayList = arrayListOf(), + @SerializedName("latestThreeEventNews") var eventNotices: ArrayList = arrayListOf(), + @SerializedName("latestThreeAcademicNews") var academicNews: ArrayList = arrayListOf() +) { + fun toVO() = + TopThreeNoticeVO( + general = this.generalNotices.map { it.toVO() }, + scholarship = this.scholarshipNotices.map { it.toVO() }, + event = this.eventNotices.map { it.toVO() }, + academic = this.academicNews.map { it.toVO() } + ) +} diff --git a/core/network/src/main/kotlin/com/doyoonkim/network/model/dto/TopicSubscriptionPreferencesDTO.kt b/core/network/src/main/kotlin/com/doyoonkim/network/model/dto/TopicSubscriptionPreferencesDTO.kt new file mode 100644 index 00000000..3b25405b --- /dev/null +++ b/core/network/src/main/kotlin/com/doyoonkim/network/model/dto/TopicSubscriptionPreferencesDTO.kt @@ -0,0 +1,25 @@ +package com.doyoonkim.network.model.dto + +import com.doyoonkim.model.TopicSubscriptionPreferencesVO +import com.google.gson.annotations.SerializedName + +/** + * @author kimdoyoon + * Created 6/3/25 at 12:23 AM + */ +data class TopicSubscriptionPreferencesDTO( + @SerializedName("generalNewsTopic") var generalNewsTopic: Boolean = false, + @SerializedName("scholarshipNewsTopic") var scholarshipNewsTopic: Boolean = false, + @SerializedName("eventNewsTopic") var eventNewsTopic: Boolean = false, + @SerializedName("academicNewsTopic") var academicNewsTopic: Boolean = false, + @SerializedName("employmentNewsTopic") var employmentNewsTopic: Boolean = false +) { + fun toVO() = + TopicSubscriptionPreferencesVO( + general = this.generalNewsTopic, + scholarship = this.scholarshipNewsTopic, + event = this.eventNewsTopic, + academic = this.academicNewsTopic, + employment = this.employmentNewsTopic + ) +} diff --git a/core/network/src/main/kotlin/com/doyoonkim/network/retrofit/KnuticeService.kt b/core/network/src/main/kotlin/com/doyoonkim/network/retrofit/KnuticeService.kt new file mode 100644 index 00000000..36c5a975 --- /dev/null +++ b/core/network/src/main/kotlin/com/doyoonkim/network/retrofit/KnuticeService.kt @@ -0,0 +1,69 @@ +package com.doyoonkim.network.retrofit + +import com.doyoonkim.model.NoticeCategory +import com.doyoonkim.network.model.DeviceTokenRequest +import com.doyoonkim.network.model.TopicSubscriptionPreferencesRequest +import com.doyoonkim.network.model.UserReportRequest +import model.NoticeByIdResult +import model.NoticesByKeywordResult +import model.NoticesPerPageResult +import model.PostResult +import model.TopThreeNoticeResults +import model.TopicSubscriptionPreferencesResult +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Headers +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * @author kimdoyoon + * Created 6/2/25 at 10:58 PM + */ + +interface KnuticeService { + @GET("open-api/notice") + suspend fun getTopThreeNotices(): TopThreeNoticeResults + + @GET("open-api/notice/list") + suspend fun getNoticesPerPage( + @Query("noticeName") category: NoticeCategory, + @Query("nttId") lastNttId: Int? = null + ): NoticesPerPageResult + + @GET("open-api/notice/{nttId}") + suspend fun getNoticeById( + @Path("nttId") nttId: String + ): NoticeByIdResult + + @GET("open-api/search") + suspend fun getNoticesByKeyword( + @Query("keyword") keyword: String + ): NoticesByKeywordResult + + @GET("open-api/topic") + suspend fun getTopicSubscriptionStatus( + @Header("fcmToken") token: String + ): TopicSubscriptionPreferencesResult + + @Headers("Content-Type: application/json") + @POST("open-api/fcm") + suspend fun validateToken( + @Body request: DeviceTokenRequest + ): PostResult + + @Headers("Content-Type: application/json") + @POST("open-api/report") + suspend fun submitUserReport( + @Body request: UserReportRequest + ): PostResult + + @Headers("Content-Type: application/json") + @POST("open-api/topic") + suspend fun submitTopicSubscriptionPreferences( + @Body request: TopicSubscriptionPreferencesRequest + ): PostResult + +} \ No newline at end of file diff --git a/core/network/src/test/java/com/doyoonkim/network/ExampleUnitTest.kt b/core/network/src/test/java/com/doyoonkim/network/ExampleUnitTest.kt new file mode 100644 index 00000000..eef945a5 --- /dev/null +++ b/core/network/src/test/java/com/doyoonkim/network/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.doyoonkim.network + +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 00000000..42afabfd --- /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 00000000..0ad1f7da --- /dev/null +++ b/core/notification/build.gradle.kts @@ -0,0 +1,58 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + + // For Dagger + id("kotlin-kapt") +} + +android { + namespace = "com.doyoonkim.notification" + compileSdk = 35 + + defaultConfig { + minSdk = 31 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + implementation(projects.core.domain) + implementation(projects.common) + implementation(projects.core.model) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + + implementation(libs.dagger) + implementation(libs.dagger.android) + implementation(libs.dagger.android.support) + kapt(libs.dagger.compiler) + kapt(libs.dagger.android.processor) + + implementation(libs.firebase.messaging) + implementation(libs.firebase.messaging.directboot) +} \ No newline at end of file diff --git a/core/notification/consumer-rules.pro b/core/notification/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/core/notification/proguard-rules.pro b/core/notification/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /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/com/doyoonkim/notification/ExampleInstrumentedTest.kt b/core/notification/src/androidTest/java/com/doyoonkim/notification/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..beb1dc31 --- /dev/null +++ b/core/notification/src/androidTest/java/com/doyoonkim/notification/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.doyoonkim.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("com.doyoonkim.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 00000000..a5918e68 --- /dev/null +++ b/core/notification/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/core/notification/src/main/java/com/doyoonkim/notification/di/NotificationModule.kt b/core/notification/src/main/java/com/doyoonkim/notification/di/NotificationModule.kt new file mode 100644 index 00000000..59fd848a --- /dev/null +++ b/core/notification/src/main/java/com/doyoonkim/notification/di/NotificationModule.kt @@ -0,0 +1,29 @@ +package com.doyoonkim.notification.di + +import android.content.Context +import com.doyoonkim.common.BitmapHandler +import com.doyoonkim.common.di.ApplicationContext +import com.doyoonkim.domain.ImageRepository +import com.doyoonkim.domain.usecases.ValidateDeviceToken +import com.doyoonkim.notification.fcm.PushNotificationHandler +import dagger.Module +import dagger.Provides +import javax.inject.Singleton + +@Module +object NotificationModule { + + @Provides + @Singleton + fun providesPushNotificationHandler( + imageRepository: ImageRepository, + bitmapHandler: BitmapHandler, + @ApplicationContext context: Context + ) = + PushNotificationHandler( + imageRepository, + bitmapHandler, + context + ) + +} \ No newline at end of file diff --git a/core/notification/src/main/java/com/doyoonkim/notification/fcm/PushNotificationHandler.kt b/core/notification/src/main/java/com/doyoonkim/notification/fcm/PushNotificationHandler.kt new file mode 100644 index 00000000..f1770ecc --- /dev/null +++ b/core/notification/src/main/java/com/doyoonkim/notification/fcm/PushNotificationHandler.kt @@ -0,0 +1,149 @@ +package com.doyoonkim.notification.fcm + +import android.Manifest +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.drawable.Icon +import android.net.Uri +import android.util.Log +import androidx.annotation.RequiresPermission +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.net.toUri +import com.doyoonkim.common.BitmapHandler +import com.doyoonkim.common.R +import com.doyoonkim.domain.ImageRepository +import com.google.firebase.messaging.RemoteMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import javax.inject.Inject +import kotlin.random.Random + + +class PushNotificationHandler @Inject constructor( + private val imageRepository: ImageRepository, + private val bitMapHandler: BitmapHandler, + private val context: Context +) { + private val TAG = "PushNotificationHandler" + + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) + fun handleReceivedMessage(message: RemoteMessage) { + // When the app is in background or killed, Data Payload would be delivered once the user + // clicks the system tray. + Log.d(TAG, "Message data payload: ${message.notification}") + + if (message.data.isNotEmpty()) { + Log.d(TAG, "Message Data Payload: ${message.data}") // message.data: Map + + message.toPushNotification() + + + // Apply "Do not disturb" option. (Temporarily save the message and deliver after the core time is end. + // Use Local Database (Room?) + } + } + + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) + private fun RemoteMessage.toPushNotification() { + + // Create Pending Intent (For access push notification while the app is in foreground) + // TODO: Migrate to Deep Link. + val nttId = this@toPushNotification.data["nttId"] + val url = this@toPushNotification.data["contentUrl"] + val imageUrl = this@toPushNotification.data["contentImage"] + val fabVisible = true + + // Deeplink featured by Jetpack Navigation won't work because notification payload consumes custom-defined deeplink intent using ACTION_VIEW + val deeplinkIntent = Intent( + Intent.ACTION_VIEW, + "knutice://service/noticeDetail/$nttId/${Uri.encode(url)}/$fabVisible".toUri() + ).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + + val pendingIntent = PendingIntent.getActivity( + context, + 0, + deeplinkIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notificationId = Random(System.currentTimeMillis().toInt()).nextInt() + // Utilize channel already created by FCM as default + val notificationBuilder = NotificationCompat.Builder( + context, context.getString(R.string.inapp_notification_channel_id) + ).apply { + setSmallIcon(R.mipmap.ic_launcher) + setLargeIcon(Icon.createWithResource(context, R.mipmap.ic_launcher)) + setContentTitle(context.getString(R.string.new_notice)) + setContentText(this@toPushNotification.data["contentTitle"] ?: "No message body.") + setContentIntent(pendingIntent) + setPriority(NotificationCompat.PRIORITY_DEFAULT) + setAutoCancel(true) + } + + + with(NotificationManagerCompat.from(context)) { + if (ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + Log.d("NotificationHandler", "Permission Denied") + // TODO: Consider calling + // ActivityCompat#requestPermissions + // here to request the missing permissions, and then overriding + // public void onRequestPermissionsResult(int requestCode, String[] permissions, + // int[] grantResults) + // to handle the case where the user grants the permission. See the documentation + // for ActivityCompat#requestPermissions for more details. + return + } + + // HardCoded CoroutineScope for Testing + val completionMarker = Job() // Variable for manual Cancellation + imageUrl?.let { url -> + CoroutineScope(Dispatchers.IO + completionMarker).launch { + val bitmapImage = async { + runCatching { + withTimeout(5000L) { + imageRepository.getImageByteArrayFromUrl(url)?.let { b -> + bitMapHandler.decodeByteArray(b) + } + } + }.onFailure { + Log.d(TAG, "Unable to retrieve bitmap image\nREASON: ${it.stackTrace}") + } + } + + bitmapImage.await().fold( + onSuccess = { result -> + result?.let { + notificationBuilder.apply { + setStyle( + NotificationCompat.BigPictureStyle() + .bigPicture(it) + ) + } + } + }, + onFailure = { + Log.d(TAG, "No Bitmap Image to be added\nREASON: ${it.stackTrace}") + } + ).also { + notify(notificationId, notificationBuilder.build()) + completionMarker.complete() + } + } + } + } + } +} \ No newline at end of file diff --git a/core/notification/src/main/java/com/doyoonkim/notification/fcm/PushNotificationService.kt b/core/notification/src/main/java/com/doyoonkim/notification/fcm/PushNotificationService.kt new file mode 100644 index 00000000..5fcc5059 --- /dev/null +++ b/core/notification/src/main/java/com/doyoonkim/notification/fcm/PushNotificationService.kt @@ -0,0 +1,52 @@ +package com.doyoonkim.notification.fcm + +import android.Manifest +import android.content.Intent +import android.util.Log +import androidx.annotation.RequiresPermission +import com.doyoonkim.common.di.AppInjectorProvider +import com.google.firebase.messaging.Constants.MessageNotificationKeys +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import dagger.Lazy +import javax.inject.Inject + +class PushNotificationService : FirebaseMessagingService() { + @Inject lateinit var handlerProvider: Lazy + private val TAG = "PushNotificationHandler" + + override fun onCreate() { + super.onCreate() + // For Field Injection + (applicationContext as AppInjectorProvider).appInjector.inject(this) + Log.d(TAG, "Initialized?: ${::handlerProvider.isInitialized}") + Log.d(TAG, "Called") + } + + override fun onNewToken(token: String) { + super.onNewToken(token) + // POST request to send FCM Token to the Server. + Log.d(TAG, "Received Token: ${token.toString()}") +// requestCurrentToken() + } + + // Fix problem: onMessageReceived is not being called when app is in background/cold-start + override fun handleIntent(intent: Intent?) { + // Manually remove notification payload (https://medium.com/@jms8732/background에서-onmessagereceived가-호출-안되는-현상에-관하여-7595df624d91) + val newIntent = intent?.apply { + val temp = extras?.apply { + remove(MessageNotificationKeys.ENABLE_NOTIFICATION) + remove("gcm.notification.e") + } + replaceExtras(temp) + } + super.handleIntent(newIntent) + } + + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) + override fun onMessageReceived(message: RemoteMessage) { + (applicationContext as AppInjectorProvider).appInjector.inject(this) + super.onMessageReceived(message) + handlerProvider.get().handleReceivedMessage(message) + } +} \ No newline at end of file diff --git a/core/notification/src/main/java/com/doyoonkim/notification/fcm/TokenHandler.kt b/core/notification/src/main/java/com/doyoonkim/notification/fcm/TokenHandler.kt new file mode 100644 index 00000000..7d3f119b --- /dev/null +++ b/core/notification/src/main/java/com/doyoonkim/notification/fcm/TokenHandler.kt @@ -0,0 +1,42 @@ +package com.doyoonkim.notification.fcm + +import android.util.Log +import com.doyoonkim.domain.usecases.ValidateDeviceToken +import com.doyoonkim.model.requestBody.DeviceTokenBody +import com.google.android.gms.tasks.OnCompleteListener +import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject + +class TokenHandler @Inject constructor( + private val validateDeviceToken: ValidateDeviceToken +) { + private val TAG = this.javaClass.name + + fun handleCurrentTokenRequest() { + FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task -> + if (!task.isSuccessful) { + Log.d(TAG, "Incomplete task: ${task.exception}") + return@OnCompleteListener + } + + // Get new FCM registration token + val registrationToken = task.result + Log.d(TAG, "Received Token: $registrationToken") + + // POST request to upload current token to the web server. + CoroutineScope(Dispatchers.IO).launch { + Log.d(TAG, "Start validating Token") + validateDeviceToken( + DeviceTokenBody(fcmToken = registrationToken) + ).collectLatest { result -> + if (result) Log.d(TAG, "Validation Successful") + else Log.d(TAG, "Unable to validate") + } + } + }) + } +} \ No newline at end of file diff --git a/core/notification/src/main/java/com/doyoonkim/notification/local/AlarmReceiver.kt b/core/notification/src/main/java/com/doyoonkim/notification/local/AlarmReceiver.kt new file mode 100644 index 00000000..c2f4702d --- /dev/null +++ b/core/notification/src/main/java/com/doyoonkim/notification/local/AlarmReceiver.kt @@ -0,0 +1,20 @@ +package com.doyoonkim.notification.local + +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +class AlarmReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + context?.let { + val notificationManager = it.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + RunnerNotifier(notificationManager, context).run { + showNotification( + content = intent?.getStringExtra("content") ?: "", + uriString = intent?.getStringExtra("uri_string") ?: "" + ) + } + } + } +} \ No newline at end of file diff --git a/core/notification/src/main/java/com/doyoonkim/notification/local/NotificationAlarmScheduler.kt b/core/notification/src/main/java/com/doyoonkim/notification/local/NotificationAlarmScheduler.kt new file mode 100644 index 00000000..933a0eb3 --- /dev/null +++ b/core/notification/src/main/java/com/doyoonkim/notification/local/NotificationAlarmScheduler.kt @@ -0,0 +1,81 @@ +package com.doyoonkim.notification.local + +import android.Manifest +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresPermission +import com.doyoonkim.common.di.ApplicationContext +import com.doyoonkim.common.navigation.BookmarkInfo +import com.doyoonkim.model.BookmarkVO +import javax.inject.Inject + +/** + * Local Push Notification (Bookmark Reminder) + * Reference: https://medium.com/@tolgapirim25/send-notifications-at-a-specific-time-with-alarm-manager-on-android-13c7cc9d8e7a + */ +interface AlarmScheduler { + fun createPendingIntent(target: BookmarkVO, nav: BookmarkInfo): PendingIntent + + fun schedule(target: BookmarkVO, nav: BookmarkInfo) + + fun cancel(target: BookmarkVO, nav: BookmarkInfo) +} + +class NotificationAlarmScheduler @Inject constructor( + @ApplicationContext private val context: Context +) : AlarmScheduler { + private val TAG = "NotificationAlarmScheduler" + // AlarmManager Instance + // Context: ApplicationContext + private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + + override fun createPendingIntent(target: BookmarkVO, nav: BookmarkInfo): PendingIntent { + val uri = "knutice://service/bookmark/${nav.noticeId}/${nav.noticeTitle}/${nav.noticeInfo}" + val intent = Intent(context, AlarmReceiver::class.java) + .apply { + putExtra("content", target.bookmarkNote) + putExtra("uri_string", uri) + } + + return PendingIntent.getBroadcast( + context, + target.targetNoticeNttId, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + // Permission Declared and granted at the APP level entry point. + @RequiresPermission(Manifest.permission.SCHEDULE_EXACT_ALARM) + override fun schedule(target: BookmarkVO, nav: BookmarkInfo) { + // Android Version Check + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (!alarmManager.canScheduleExactAlarms()) { + Log.d(TAG, "Schedule Exact Alarms is not possible.") + return + } + } + + Log.d(TAG, "Alarm is being scheduled.") + alarmManager.setExact( + AlarmManager.RTC_WAKEUP, + target.reminderSchedule, + createPendingIntent(target, nav) + ).also { Log.d(TAG, "Alarm Scheduled.") } + } + + override fun cancel(target: BookmarkVO, nav: BookmarkInfo) { + alarmManager.cancel( + createPendingIntent(target, nav) + ) + } + + fun canScheduleExactAlarms(): Boolean { + return alarmManager.canScheduleExactAlarms() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/alarm/Notifier.kt b/core/notification/src/main/java/com/doyoonkim/notification/local/Notifier.kt similarity index 50% rename from app/src/main/java/com/doyoonkim/knutice/alarm/Notifier.kt rename to core/notification/src/main/java/com/doyoonkim/notification/local/Notifier.kt index 4e687ee9..85d3ebe8 100644 --- a/app/src/main/java/com/doyoonkim/knutice/alarm/Notifier.kt +++ b/core/notification/src/main/java/com/doyoonkim/notification/local/Notifier.kt @@ -1,25 +1,23 @@ -package com.doyoonkim.knutice.alarm +package com.doyoonkim.notification.local import android.app.Notification import android.app.NotificationManager -import android.os.Build -import com.doyoonkim.knutice.model.Bookmark -abstract class Notifier ( +abstract class Notifier( private val notificationManager: NotificationManager ) { abstract val channelId: String abstract val channelName: String abstract val notificationId: Int - fun showNotification(note: String) { - // Use existing notification channel - val notification = buildNotification(note) + fun showNotification(content: String, uriString: String) { + // Use existing notification channel: KNUTICE-IN-APP-Channel + val notification = buildNotification(content, uriString) notificationManager.notify( notificationId, notification ) } - abstract fun buildNotification(note: String): Notification + abstract fun buildNotification(content: String, uriString: String): Notification } \ No newline at end of file diff --git a/core/notification/src/main/java/com/doyoonkim/notification/local/RunnerNotifier.kt b/core/notification/src/main/java/com/doyoonkim/notification/local/RunnerNotifier.kt new file mode 100644 index 00000000..3463398f --- /dev/null +++ b/core/notification/src/main/java/com/doyoonkim/notification/local/RunnerNotifier.kt @@ -0,0 +1,48 @@ +package com.doyoonkim.notification.local + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.net.toUri +import com.doyoonkim.common.R +import kotlin.random.Random + +class RunnerNotifier( + private val notificationManager: NotificationManager, + private val context: Context +) : Notifier(notificationManager) { + override val channelId: String + get() = context.getString(R.string.inapp_notification_channel_id) + override val channelName: String + get() = context.getString(R.string.inapp_notificaiton_channel_name) + override val notificationId: Int + get() = Random(System.currentTimeMillis()).nextInt() + + override fun buildNotification(content: String, uriString: String): Notification { + // Create Intent + val deeplinkIntent = Intent( + Intent.ACTION_VIEW, + uriString.toUri() + ).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + }.run { + PendingIntent.getActivity( + context, + 0, + this, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + // Custom-define notification builder. + return NotificationCompat.Builder(context, channelId) + .setContentTitle(context.getString(R.string.text_reminder_title)) + .setContentText(content) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentIntent(deeplinkIntent) + .build() + } +} \ No newline at end of file diff --git a/core/notification/src/test/java/com/doyoonkim/notification/ExampleUnitTest.kt b/core/notification/src/test/java/com/doyoonkim/notification/ExampleUnitTest.kt new file mode 100644 index 00000000..c8208c0e --- /dev/null +++ b/core/notification/src/test/java/com/doyoonkim/notification/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.doyoonkim.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/feature/bookmark/.gitignore b/feature/bookmark/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/bookmark/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/bookmark/build.gradle.kts b/feature/bookmark/build.gradle.kts new file mode 100644 index 00000000..a77bd111 --- /dev/null +++ b/feature/bookmark/build.gradle.kts @@ -0,0 +1,78 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + id("kotlin-kapt") + + // Required from Kotlin 2.0.0 (Every module using Compose) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "com.doyoonkim.bookmark" + compileSdk = 35 + + defaultConfig { + minSdk = 30 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + implementation(projects.common) + implementation(projects.core.domain) + implementation(projects.core.model) + implementation(projects.core.notification) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + + // Universally applied to module uses UI feature + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.material) + + + testImplementation(libs.junit) + + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + + // Dagger + implementation(libs.dagger) + implementation(libs.dagger.android) + implementation(libs.dagger.android.support) + kapt(libs.dagger.compiler) + kapt(libs.dagger.android.processor) + + implementation(libs.androidx.navigation.compose) +} \ No newline at end of file diff --git a/feature/bookmark/consumer-rules.pro b/feature/bookmark/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/bookmark/proguard-rules.pro b/feature/bookmark/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/feature/bookmark/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/feature/bookmark/src/androidTest/java/com/doyoonkim/bookmark/ExampleInstrumentedTest.kt b/feature/bookmark/src/androidTest/java/com/doyoonkim/bookmark/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..d91443f7 --- /dev/null +++ b/feature/bookmark/src/androidTest/java/com/doyoonkim/bookmark/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.doyoonkim.bookmark + +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("com.doyoonkim.bookmark.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/feature/bookmark/src/main/AndroidManifest.xml b/feature/bookmark/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/bookmark/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/bookmark/src/main/java/com/doyoonkim/bookmark/bookmarkServiceGraph.kt b/feature/bookmark/src/main/java/com/doyoonkim/bookmark/bookmarkServiceGraph.kt new file mode 100644 index 00000000..8ddbfcbd --- /dev/null +++ b/feature/bookmark/src/main/java/com/doyoonkim/bookmark/bookmarkServiceGraph.kt @@ -0,0 +1,69 @@ +package com.doyoonkim.bookmark + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.navDeepLink +import com.doyoonkim.bookmark.edit.EditBookmarkScreen +import com.doyoonkim.bookmark.list.BookmarkListScreen +import com.doyoonkim.bookmark.viewmodel.BookmarkListViewModel +import com.doyoonkim.bookmark.viewmodel.EditBookmarkViewModel +import com.doyoonkim.common.navigation.BookmarkInfo +import com.doyoonkim.common.navigation.NavRoutes +import com.doyoonkim.common.navigation.NoticeDetail + +fun NavGraphBuilder.bookmarkServiceGraph( + navController: NavController, + viewModelFactory: ViewModelProvider.Factory, + contentPadding: PaddingValues, + onNoticeDetailRequested: (NoticeDetail) -> Unit +) { + + composable(NavRoutes.Bookmark.route) { + BookmarkListScreen( + modifier = Modifier.padding(5.dp), + viewModel = viewModel(factory = viewModelFactory), + bottomPadding = contentPadding.calculateBottomPadding(), + onBookmarkSelected = { + navController.navigate("bookmark/${it.noticeId}/${it.noticeTitle}/${it.noticeInfo}") + }, + onBackPressed = { navController.popBackStack() } + ) + } + + composable( + route = "bookmark/{id}/{title}/{info}", + deepLinks = listOf( + navDeepLink { + uriPattern = "knutice://service/bookmark/{id}/{title}/{info}" + } + ) + ) { backStackEntry -> + val bookmarkInfo = backStackEntry.arguments?.let { + BookmarkInfo( + it.getString("id")?.toInt() ?: 0, + it.getString("title") ?: "", + it.getString("info") ?: "" + ) + } ?: BookmarkInfo(0, "", "") + + EditBookmarkScreen( + modifier = Modifier.padding(5.dp), + viewModel = viewModel(factory = viewModelFactory), + bookmarkInfo = bookmarkInfo, + onNoticeSelected = { onNoticeDetailRequested(it) }, + onBackPressed = { navController.navigate(NavRoutes.Bookmark.route) { + popUpTo(navController.graph.startDestinationId) { + inclusive = true + } + } } + ) + } + +} \ No newline at end of file diff --git a/feature/bookmark/src/main/java/com/doyoonkim/bookmark/di/BookmarkModule.kt b/feature/bookmark/src/main/java/com/doyoonkim/bookmark/di/BookmarkModule.kt new file mode 100644 index 00000000..c6432eb9 --- /dev/null +++ b/feature/bookmark/src/main/java/com/doyoonkim/bookmark/di/BookmarkModule.kt @@ -0,0 +1,24 @@ +package com.doyoonkim.bookmark.di + +import androidx.lifecycle.ViewModel +import com.doyoonkim.bookmark.viewmodel.BookmarkListViewModel +import com.doyoonkim.bookmark.viewmodel.EditBookmarkViewModel +import com.doyoonkim.common.di.ViewModelKey +import dagger.Binds +import dagger.Module +import dagger.multibindings.IntoMap + +@Module +abstract class BookmarkModule { + + @Binds + @IntoMap + @ViewModelKey(BookmarkListViewModel::class) + abstract fun bindsBookmarkListViewModel(viewModel: BookmarkListViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(EditBookmarkViewModel::class) + abstract fun bindsEditBookmarkViewModel(viewModel: EditBookmarkViewModel): ViewModel + +} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/EditBookmark.kt b/feature/bookmark/src/main/java/com/doyoonkim/bookmark/edit/EditBookmarkScreen.kt similarity index 70% rename from app/src/main/java/com/doyoonkim/knutice/presentation/EditBookmark.kt rename to feature/bookmark/src/main/java/com/doyoonkim/bookmark/edit/EditBookmarkScreen.kt index 71807e57..57e356da 100644 --- a/app/src/main/java/com/doyoonkim/knutice/presentation/EditBookmark.kt +++ b/feature/bookmark/src/main/java/com/doyoonkim/bookmark/edit/EditBookmarkScreen.kt @@ -1,6 +1,7 @@ -package com.doyoonkim.knutice.presentation +package com.doyoonkim.bookmark.edit import android.util.Log +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically @@ -14,36 +15,36 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -51,23 +52,22 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel -import com.doyoonkim.knutice.R -import com.doyoonkim.knutice.model.Bookmark -import com.doyoonkim.knutice.model.Notice -import com.doyoonkim.knutice.presentation.component.DateTimePicker -import com.doyoonkim.knutice.presentation.component.NotificationPreviewCard -import com.doyoonkim.knutice.ui.theme.containerBackground -import com.doyoonkim.knutice.ui.theme.subTitle -import com.doyoonkim.knutice.ui.theme.title -import com.doyoonkim.knutice.viewModel.EditBookmarkViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.doyoonkim.bookmark.viewmodel.EditBookmarkViewModel +import com.doyoonkim.common.navigation.BookmarkInfo +import com.doyoonkim.common.navigation.NoticeDetail +import com.doyoonkim.common.ui.DateTimePicker +import com.doyoonkim.common.R +import com.doyoonkim.common.theme.buttonPurple +import com.doyoonkim.common.theme.containerBackground +import com.doyoonkim.common.theme.subTitle +import com.doyoonkim.common.theme.title +import com.doyoonkim.common.ui.NotificationPreviewCard import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.text.SimpleDateFormat @@ -76,32 +76,48 @@ import java.util.Date import java.util.Locale import java.util.TimeZone +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun EditBookmark( +fun EditBookmarkScreen( modifier: Modifier = Modifier, - viewModel: EditBookmarkViewModel = hiltViewModel(), - onDetailedNoticeRequested: (Notice) -> Unit, - onSaveClicked: (Bookmark?) -> Unit = { } + viewModel: EditBookmarkViewModel, + bookmarkInfo: BookmarkInfo, + onNoticeSelected: (NoticeDetail) -> Unit, + onBackPressed: () -> Unit ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val adjustImePadding = Modifier.consumeWindowInsets(WindowInsets.ime).imePadding() - LaunchedEffect(uiState.isReminderRequested) { - if (uiState.timeForRemind == 0L) { - viewModel.updateReminderOptions(updatedTimeForRemind = System.currentTimeMillis()) + BackHandler { + onBackPressed() + } + + // Once per composition? + LaunchedEffect(Unit) { + viewModel.apply { + createBookmarkInfo(bookmarkInfo) + getBookmarkByNoticeId(bookmarkInfo.noticeId) + getNoticeById(bookmarkInfo.noticeId) } } + LaunchedEffect(uiState.isReminderRequested) { + if (uiState.timeForRemind == 0L) + viewModel.updateReminderOptions(timeForRemind = System.currentTimeMillis()) + } + Column( modifier = modifier.fillMaxSize() + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)) ) { NotificationPreviewCard( modifier = Modifier.padding(5.dp), - notificationTitle = uiState.targetNotice.title, - notificationInfo = uiState.targetNotice.departName + isLoading = false, + notificationTitle = bookmarkInfo.noticeTitle, + notificationInfo = bookmarkInfo.noticeInfo ) { // Request Full Content - onDetailedNoticeRequested(uiState.targetNotice) + uiState.targetNotice?.let { onNoticeSelected(NoticeDetail(it.nttId, it.url, false)) } } Spacer(Modifier.height(30.dp)) @@ -142,7 +158,7 @@ fun EditBookmark( checked = uiState.isReminderRequested, enabled = true, modifier = Modifier.padding(10.dp).weight(1f), - onCheckedChange = { viewModel.updateReminderOptions(reminderRequested = !uiState.isReminderRequested) } + onCheckedChange = { viewModel.updateReminderOptions(requested = !uiState.isReminderRequested) } ) } AnimatedVisibility( @@ -162,10 +178,11 @@ fun EditBookmark( Text( modifier = Modifier.fillMaxWidth().padding(20.dp) .clickable { viewModel.updateReminderOptions( - updatedDatePickerVisible = !uiState.datePickerVisible + isDatePickerVisible = !uiState.datePickerVisible ) }, text = uiState.timeForRemind.toFormattedDate( - SimpleDateFormat("yyyy/MM/dd a HH:mm", Locale.getDefault())), + SimpleDateFormat("yyyy/MM/dd a HH:mm", Locale.getDefault()) + ), textAlign = TextAlign.Start, fontWeight = FontWeight.Normal, fontSize = 15.sp, @@ -232,16 +249,16 @@ fun EditBookmark( modifier = Modifier.wrapContentHeight() .weight(1f), enabled = true, + colors = ButtonDefaults.buttonColors().copy( + containerColor = MaterialTheme.colorScheme.buttonPurple, + contentColor = Color.White, + ), shape = RoundedCornerShape(10.dp), onClick = { - if (!uiState.requireCreation) { - viewModel.modifyBookmark() - } else { - viewModel.createNewBookmark() - } + viewModel.submitBookmark() coroutineScope.launch { delay(500L) - onSaveClicked(uiState.bookmarkInstance) +// onSaveClicked(uiState.bookmarkInstance) } } ) { @@ -254,13 +271,17 @@ fun EditBookmark( } if (!uiState.requireCreation) { - OutlinedButton( + Button( modifier = Modifier.wrapContentHeight().weight(1f), enabled = true, shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors().copy( + containerColor = MaterialTheme.colorScheme.subTitle, + contentColor = Color.White + ), onClick = { viewModel.removeBookmark() - onSaveClicked(null) +// onSaveClicked(null) } ) { Text( @@ -284,7 +305,7 @@ fun EditBookmark( Box( modifier = Modifier.fillMaxSize().padding(start = 5.dp, end = 5.dp) .clickable { viewModel.updateReminderOptions( - updatedDatePickerVisible = !uiState.datePickerVisible) + isDatePickerVisible = !uiState.datePickerVisible) } ) { DateTimePicker( @@ -295,25 +316,52 @@ fun EditBookmark( if (it != null) { Log.d("EditBookmark", "${it}") viewModel.updateReminderOptions( - updatedTimeForRemind = it, - updatedDatePickerVisible = !uiState.datePickerVisible + timeForRemind = it, + isDatePickerVisible = !uiState.datePickerVisible ) } } } + } + + if (uiState.isCompleted) { + BasicAlertDialog( + onDismissRequest = { + // Dismiss the dialog when the user clicks outside the dialog or on the back + // button. If you want to disable that functionality, simply use an empty + // onDismissRequest. + } + ) { + Surface( + modifier = Modifier.wrapContentWidth().wrapContentHeight(), + shape = MaterialTheme.shapes.large, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + Column(modifier = Modifier.padding(30.dp)) { + Text( + text = if (uiState.isSuccessful) { + stringResource(R.string.text_save_successful) + } else { + stringResource(R.string.text_save_unsuccessful) + } + ) + Spacer(modifier = Modifier.height(24.dp)) + TextButton( + onClick = { + viewModel.updateCompletionStatus(false) + if (uiState.isSuccessful) onBackPressed() + }, + modifier = Modifier.align(Alignment.End) + ) { + Text(stringResource(R.string.btn_confirm)) + } + } + } + } } } private fun Long.toFormattedDate(f: SimpleDateFormat): String { return f.apply { timeZone = TimeZone.getTimeZone(ZoneId.systemDefault()) }.format(Date(this)) -} - - - -@Preview(showBackground = true) -@Composable -fun EditBookmark_Preview() { -// EditBookmark(Modifier.fillMaxSize().padding(10.dp)) { } -} - +} \ No newline at end of file diff --git a/feature/bookmark/src/main/java/com/doyoonkim/bookmark/list/BookmarkListScreen.kt b/feature/bookmark/src/main/java/com/doyoonkim/bookmark/list/BookmarkListScreen.kt new file mode 100644 index 00000000..e3884eef --- /dev/null +++ b/feature/bookmark/src/main/java/com/doyoonkim/bookmark/list/BookmarkListScreen.kt @@ -0,0 +1,106 @@ +package com.doyoonkim.bookmark.list + +import android.util.Log +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.doyoonkim.bookmark.viewmodel.BookmarkListViewModel +import com.doyoonkim.common.navigation.BookmarkInfo +import com.doyoonkim.common.theme.containerBackgroundSolid +import com.doyoonkim.common.R +import com.doyoonkim.common.theme.subTitle +import com.doyoonkim.common.ui.NotificationPreviewCardMarked + +@Composable +fun BookmarkListScreen( + modifier: Modifier = Modifier, + viewModel: BookmarkListViewModel, + bottomPadding: Dp = 0.dp, + onBookmarkSelected: (BookmarkInfo) -> Unit, + onBackPressed: () -> Unit +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + BackHandler { onBackPressed() } + + LaunchedEffect(Unit) { + viewModel.getAllBookmarks() + } + + Box( + modifier = modifier.fillMaxSize() + .background(MaterialTheme.colorScheme.containerBackgroundSolid) + .systemBarsPadding() + ) { + if (uiState.bookmarks.isEmpty()) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.text_no_bookmark), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.subTitle + ) + Spacer(Modifier.height(bottomPadding)) + } + } else { + LazyColumn( + modifier = Modifier.wrapContentHeight() + .fillMaxWidth() + .padding(top = 12.dp, bottom = bottomPadding) + .background(Color.Transparent), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + items( + items = uiState.bookmarks, + key = { it.second.nttId } + ) { + // Being called 3 times + Log.d("BookmarkComposable", "Element: $it") + NotificationPreviewCardMarked( + noticeTitle = it.second.title, + noticeSubtitle = "[${it.second.departName}] ${it.second.timestamp}", + onItemClicked = { onBookmarkSelected( + it.second.run { + BookmarkInfo( + noticeId = this.nttId, + noticeTitle = this.title, + noticeInfo = "[${this.departName}] ${this.timestamp}" + ) + } + ) } + ) + } + } + } + } +} \ No newline at end of file diff --git a/feature/bookmark/src/main/java/com/doyoonkim/bookmark/viewmodel/BookmarkListViewModel.kt b/feature/bookmark/src/main/java/com/doyoonkim/bookmark/viewmodel/BookmarkListViewModel.kt new file mode 100644 index 00000000..fb17f337 --- /dev/null +++ b/feature/bookmark/src/main/java/com/doyoonkim/bookmark/viewmodel/BookmarkListViewModel.kt @@ -0,0 +1,57 @@ +package com.doyoonkim.bookmark.viewmodel + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.doyoonkim.domain.usecases.FetchAllBookmarks +import com.doyoonkim.model.BookmarkVO +import com.doyoonkim.model.NoticeVO +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +class BookmarkListViewModel @Inject constructor( + private val fetchAllBookmarks: FetchAllBookmarks +) : ViewModel() { + + private var _uiState = MutableStateFlow(BookmarkListState()) + val uiState = _uiState.asStateFlow() + + fun getAllBookmarks() { + updateFetchingStatus(false) + viewModelScope.launch { + fetchAllBookmarks() + .flowOn(Dispatchers.IO) + .collectLatest { result -> + Log.d("BookmarkListViewModel", "${result}") + _uiState.update { + it.copy( + bookmarks = it.bookmarks.toMutableList().apply { + this.add(result) + }.distinctBy { e -> e.first.bookmarkId }.toList() + ) + } + } + }.run { if (this.isCompleted) updateFetchingStatus(true) } + } + + fun updateFetchingStatus(status: Boolean) = + _uiState.update { + it.copy( + isFetchingCompleted = status + ) + } + + +} + +data class BookmarkListState( + val bookmarks: List> = emptyList(), + val isRefreshing: Boolean = false, + val isFetchingCompleted: Boolean = true, +) \ No newline at end of file diff --git a/feature/bookmark/src/main/java/com/doyoonkim/bookmark/viewmodel/EditBookmarkViewModel.kt b/feature/bookmark/src/main/java/com/doyoonkim/bookmark/viewmodel/EditBookmarkViewModel.kt new file mode 100644 index 00000000..8d5ccae4 --- /dev/null +++ b/feature/bookmark/src/main/java/com/doyoonkim/bookmark/viewmodel/EditBookmarkViewModel.kt @@ -0,0 +1,206 @@ +package com.doyoonkim.bookmark.viewmodel + +import androidx.annotation.RequiresPermission +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.doyoonkim.common.navigation.BookmarkInfo +import com.doyoonkim.domain.usecases.FetchNoticeByIdFromLocal +import com.doyoonkim.domain.usecases.ModifyBookmark +import com.doyoonkim.model.BookmarkVO +import com.doyoonkim.model.NoticeVO +import com.doyoonkim.notification.local.NotificationAlarmScheduler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +class EditBookmarkViewModel @Inject constructor( + private val modifyBookmark: ModifyBookmark, + private val fetchNoticeByIdLocal: FetchNoticeByIdFromLocal, + private val alarmScheduler: NotificationAlarmScheduler +) : ViewModel() { + + private var _uiState = MutableStateFlow(EditBookmarkState()) + val uiState = _uiState.asStateFlow() + + private var bookmarkNav: BookmarkInfo? = null + + fun getBookmarkByNoticeId(nttId: Int) = + viewModelScope.launch { + modifyBookmark.query(nttId) + .flowOn(Dispatchers.IO) + .collectLatest { result -> + _uiState.update { + it.copy( + bookmarkId = result.bookmarkId, + isReminderRequested = result.isScheduled, + timeForRemind = result.reminderSchedule, + bookmarkNote = result.bookmarkNote, + requireCreation = false, + bookmarkInstances = result + ) + } + } + } + + fun getNoticeById(nttId: Int) { + _uiState.update { + it.copy( + targetNoticeId = nttId + ) + } + + viewModelScope.launch { + fetchNoticeByIdLocal(nttId) + .flowOn(Dispatchers.IO) + .collectLatest { notice -> + _uiState.update { + it.copy( + targetNotice = notice + ) + } + } + } + } + + fun createBookmarkInfo(info: BookmarkInfo) { + bookmarkNav = info + } + + fun updateCompletionStatus(status: Boolean) = + _uiState.update { + it.copy( + isCompleted = status + ) + } + + fun updateReminderOptions( + requested: Boolean = uiState.value.isReminderRequested, + timeForRemind: Long = uiState.value.timeForRemind, + isDatePickerVisible: Boolean = uiState.value.datePickerVisible + ) { + _uiState.update { + it.copy( + isReminderRequested = requested, + timeForRemind = timeForRemind, + datePickerVisible = isDatePickerVisible + ) + } + } + + fun updateBookmarkNotes(notes: String) { + if (notes.length < 500) { + _uiState.update { + it.copy( + bookmarkNote = notes + ) + } + } + } + + @RequiresPermission("android.permission.SCHEDULE_EXACT_ALARM") + fun submitBookmark() = + viewModelScope.launch { + // Bookmark creation requires getting Notice Instance. + val bookmark = uiState.value.run { + if (this.targetNotice != null) { + // Update + BookmarkVO( + bookmarkId = bookmarkId, + targetNoticeNttId = targetNoticeId, + isScheduled = isReminderRequested, + reminderSchedule = timeForRemind, + bookmarkNote = bookmarkNote + ) + } else { + // Creation + BookmarkVO( + targetNoticeNttId = targetNoticeId, + isScheduled = isReminderRequested, + reminderSchedule = timeForRemind, + bookmarkNote = bookmarkNote + ) + } + } + + modifyBookmark.createOrUpdate(bookmark, uiState.value.targetNotice) + .flowOn(Dispatchers.IO) + .collectLatest { result -> + if (result) { + // Access AlarmScheduler to set local alarm + bookmarkNav?.let { + if (bookmark.isScheduled) alarmScheduler.schedule(bookmark, it) + else alarmScheduler.cancel(bookmark, it) + } + } + _uiState.update { + it.copy( + isSuccessful = result, + isCompleted = true + ) + } + } + } + + @RequiresPermission("android.permission.SCHEDULE_EXACT_ALARM") + fun removeBookmark() { + viewModelScope.launch { + val bookmark = uiState.value.run { + BookmarkVO( + bookmarkId = bookmarkId, + targetNoticeNttId = targetNoticeId, + isScheduled = isReminderRequested, + reminderSchedule = timeForRemind, + bookmarkNote = bookmarkNote + ) + } + + if (uiState.value.targetNotice == null) { + _uiState.update { + it.copy( + isSuccessful = false, + isCompleted = true + ) + } + } else { + modifyBookmark.delete(bookmark, uiState.value.targetNotice!!) + .flowOn(Dispatchers.IO) + .collectLatest { result -> + if (result) { + // Access AlarmScheduler to set local alarm + bookmarkNav?.let { + if (bookmark.isScheduled) alarmScheduler.schedule(bookmark, it) + else alarmScheduler.cancel(bookmark, it) + } + } + _uiState.update { + it.copy( + requireCreation = true, + isSuccessful = true, + isCompleted = true + ) + } + } + } + } + } + +} + +data class EditBookmarkState( + val bookmarkId: Int = 0, + val targetNoticeId: Int = 0, + val isReminderRequested: Boolean = false, + val timeForRemind: Long = 0, + val bookmarkNote: String = "", + val requireCreation: Boolean = true, + val bookmarkInstances: BookmarkVO? = null, + val targetNotice: NoticeVO? = null, + val datePickerVisible: Boolean = false, + val isSuccessful: Boolean = false, + val isCompleted: Boolean = false +) \ No newline at end of file diff --git a/feature/bookmark/src/test/java/com/doyoonkim/bookmark/ExampleUnitTest.kt b/feature/bookmark/src/test/java/com/doyoonkim/bookmark/ExampleUnitTest.kt new file mode 100644 index 00000000..920620c0 --- /dev/null +++ b/feature/bookmark/src/test/java/com/doyoonkim/bookmark/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.doyoonkim.bookmark + +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/feature/main/.gitignore b/feature/main/.gitignore new file mode 100644 index 00000000..dd650708 --- /dev/null +++ b/feature/main/.gitignore @@ -0,0 +1,4 @@ +/build + +# local properties +local.properties \ No newline at end of file diff --git a/feature/main/build.gradle.kts b/feature/main/build.gradle.kts new file mode 100644 index 00000000..eb9b31f5 --- /dev/null +++ b/feature/main/build.gradle.kts @@ -0,0 +1,74 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + id("kotlin-kapt") + + // Required from Kotlin 2.0.0 (Every module using Compose) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "com.doyoonkim.main" + compileSdk = 35 + + defaultConfig { + minSdk = 30 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + implementation(projects.core.model) + implementation(projects.core.domain) + implementation(projects.common) + + // Universally applied to module uses UI feature + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.material) + + + testImplementation(libs.junit) + + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + + // Dagger + implementation(libs.dagger) + implementation(libs.dagger.android) + implementation(libs.dagger.android.support) + kapt(libs.dagger.compiler) + kapt(libs.dagger.android.processor) + + // Navigation for Compose + implementation(libs.androidx.navigation.compose) +} \ No newline at end of file diff --git a/feature/main/consumer-rules.pro b/feature/main/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/main/proguard-rules.pro b/feature/main/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/feature/main/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/feature/main/src/androidTest/java/com/doyoonkim/main/ExampleInstrumentedTest.kt b/feature/main/src/androidTest/java/com/doyoonkim/main/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..871571e9 --- /dev/null +++ b/feature/main/src/androidTest/java/com/doyoonkim/main/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.doyoonkim.main + +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("com.doyoonkim.main.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/feature/main/src/main/AndroidManifest.xml b/feature/main/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/main/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/main/src/main/java/com/doyoonkim/main/MainServiceNavGraph.kt b/feature/main/src/main/java/com/doyoonkim/main/MainServiceNavGraph.kt new file mode 100644 index 00000000..5b5decb6 --- /dev/null +++ b/feature/main/src/main/java/com/doyoonkim/main/MainServiceNavGraph.kt @@ -0,0 +1,188 @@ +package com.doyoonkim.main + +import android.net.Uri +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.navDeepLink +import com.doyoonkim.common.navigation.BookmarkInfo +import com.doyoonkim.common.navigation.Destination +import com.doyoonkim.common.navigation.NavRoutes +import com.doyoonkim.common.navigation.NoticeDetail +import com.doyoonkim.main.home.HomeScreen +import com.doyoonkim.main.notice.NoticeDetailScreen +import com.doyoonkim.main.notice.NoticeSearchScreen +import com.doyoonkim.main.notice.NoticesInCategoryScreen +import com.doyoonkim.main.preference.CustomerServiceScreen +import com.doyoonkim.main.preference.NotificationPreferencesScreen +import com.doyoonkim.main.preference.OssNoticeScreen +import com.doyoonkim.main.preference.UserPreferenceScreen +import com.doyoonkim.main.viewmodel.CustomerServiceViewModel +import com.doyoonkim.main.viewmodel.HomeViewModel +import com.doyoonkim.main.viewmodel.NoticeDetailViewModel +import com.doyoonkim.main.viewmodel.NoticeSearchViewModel +import com.doyoonkim.main.viewmodel.NoticesInCategoryViewModel +import com.doyoonkim.main.viewmodel.NotificationPreferencesViewModel +import com.doyoonkim.model.NoticeCategory + +fun NavGraphBuilder.mainServiceNavGraph( + navController: NavController, + viewModelFactory: ViewModelProvider.Factory, + contentPadding: PaddingValues, + onNoticeDetailRequested: (NoticeDetail) -> Unit, + onBookmarkServiceRequested: (BookmarkInfo) -> Unit +) { + // ViewModels will be injected via ViewModelFactory + composable(NavRoutes.Home.route) { + HomeScreen( + modifier = Modifier.padding(5.dp), + viewModel = viewModel(factory = viewModelFactory), + bottomPadding = contentPadding.calculateBottomPadding(), + onGoBackAction = { navController.popBackStack() }, + onMoreNoticeRequested = { dest -> + navController.run { + when(dest) { + Destination.MORE_GENERAL -> navigate(NavRoutes.GeneralNotices.route) + Destination.MORE_ACADEMIC -> navigate(NavRoutes.AcademicNotices.route) + Destination.MORE_SCHOLARSHIP -> navigate(NavRoutes.ScholarshipNotices.route) + Destination.MORE_EVENT -> navigate(NavRoutes.EventNotices.route) + else -> { /* DO NOTHING. */ } + } + } + }, + onFullContentRequested = { id, url -> + onNoticeDetailRequested(NoticeDetail(id, url)) } + ) + } + + composable(NavRoutes.NoticeSearch.route) { + NoticeSearchScreen( + modifier = Modifier.padding(5.dp), + viewModel = viewModel(factory = viewModelFactory), + onBackPressed = { navController.popBackStack() }, + onNoticeSelected = { id, url -> + onNoticeDetailRequested(NoticeDetail(id, url)) + } + ) + } + + composable(NavRoutes.GeneralNotices.route) { + NoticesInCategoryScreen( + modifier = Modifier.padding(5.dp), + category = NoticeCategory.GENERAL_NEWS, + viewModel = viewModel(factory = viewModelFactory), + onBackButtonPressed = { navController.popBackStack() }, + onNoticeSelected = { id, url -> + onNoticeDetailRequested(NoticeDetail(id, url)) + } + ) + } + + composable(NavRoutes.AcademicNotices.route) { + NoticesInCategoryScreen( + modifier = Modifier.padding(5.dp), + category = NoticeCategory.ACADEMIC_NEWS, + viewModel = viewModel(factory = viewModelFactory), + onBackButtonPressed = { navController.popBackStack() }, + onNoticeSelected = { id, url -> + onNoticeDetailRequested(NoticeDetail(id, url)) + } + ) + } + + composable(NavRoutes.ScholarshipNotices.route) { + NoticesInCategoryScreen( + modifier = Modifier.padding(5.dp), + category = NoticeCategory.SCHOLARSHIP_NEWS, + viewModel = viewModel(factory = viewModelFactory), + onBackButtonPressed = { navController.popBackStack() }, + onNoticeSelected = { id, url -> + onNoticeDetailRequested(NoticeDetail(id, url)) + } + ) + } + + composable(NavRoutes.EventNotices.route) { + NoticesInCategoryScreen( + modifier = Modifier.padding(5.dp), + category = NoticeCategory.EVENT_NEWS, + viewModel = viewModel(factory = viewModelFactory), + onBackButtonPressed = { navController.popBackStack() }, + onNoticeSelected = { id, url -> + onNoticeDetailRequested(NoticeDetail(id, url)) + } + ) + } + + // preferences + composable(NavRoutes.Settings.route) { + UserPreferenceScreen( + modifier = Modifier.padding(5.dp), + onNotificationPreferenceClicked = { navController.navigate(NavRoutes.NotificationPreferences.route) }, + onCustomerServiceClicked = { navController.navigate(NavRoutes.CustomerService.route) }, + onOssClicked = { navController.navigate(NavRoutes.OpenSource.route) }, + onBackPressed = { navController.popBackStack() } + ) + } + + composable(NavRoutes.NotificationPreferences.route) { + NotificationPreferencesScreen( + modifier = Modifier.padding(5.dp), + viewModel = viewModel(factory = viewModelFactory), + onBackPressed = { navController.popBackStack() } + ) + } + + composable(NavRoutes.CustomerService.route) { + CustomerServiceScreen( + modifier = Modifier.padding(5.dp), + viewModel = viewModel(factory = viewModelFactory), + onBackPressed = { navController.popBackStack() } + ) + } + + composable(NavRoutes.OpenSource.route) { + OssNoticeScreen( + modifier = Modifier.padding(5.dp), + onBackPressed = { navController.popBackStack() } + ) + } + + // DeepLinks for NoticeDetailScreen + composable( + route = "noticeDetail/{nttId}/{contentUrl}/{isFabVisible}", + deepLinks = listOf( + navDeepLink { + uriPattern = "knutice://service/noticeDetail/{nttId}/{contentUrl}/{isFabVisible}" + } + ) + ) { backStackEntry -> + val noticeInfo = backStackEntry.arguments?.let { + Triple( + it.getString("nttId")?.toInt() ?: 0, + Uri.decode(it.getString("contentUrl") ?: ""), + it.getString("isFabVisible").toBoolean() ?: false + ) + } ?: Triple(0, "", false) + + NoticeDetailScreen( + modifier = Modifier.fillMaxSize(), + viewModel = viewModel(factory = viewModelFactory), + noticeInfo = noticeInfo, + onBookmarkCreate = { onBookmarkServiceRequested(BookmarkInfo( + noticeId = it.nttId, + noticeTitle = it.title, + noticeInfo = "[${it.departName}] ${it.timestamp}" + )) }, + onBackPressed = { navController.popBackStack() } + ) + } +} \ No newline at end of file diff --git a/feature/main/src/main/java/com/doyoonkim/main/di/MainModule.kt b/feature/main/src/main/java/com/doyoonkim/main/di/MainModule.kt new file mode 100644 index 00000000..734716d7 --- /dev/null +++ b/feature/main/src/main/java/com/doyoonkim/main/di/MainModule.kt @@ -0,0 +1,48 @@ +package com.doyoonkim.main.di + +import androidx.lifecycle.ViewModel +import com.doyoonkim.common.di.ViewModelKey +import com.doyoonkim.main.viewmodel.CustomerServiceViewModel +import com.doyoonkim.main.viewmodel.HomeViewModel +import com.doyoonkim.main.viewmodel.NoticeDetailViewModel +import com.doyoonkim.main.viewmodel.NoticeSearchViewModel +import com.doyoonkim.main.viewmodel.NoticesInCategoryViewModel +import com.doyoonkim.main.viewmodel.NotificationPreferencesViewModel +import dagger.Binds +import dagger.Module +import dagger.multibindings.IntoMap + +@Module +abstract class MainModule { + + @Binds + @IntoMap + @ViewModelKey(HomeViewModel::class) + abstract fun bindsHomeViewModel(viewModel: HomeViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(NoticeDetailViewModel::class) + abstract fun bindsNoticeDetailViewModel(viewModel: NoticeDetailViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(NoticeSearchViewModel::class) + abstract fun bindsNoticeSearchViewModel(viewModel: NoticeSearchViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(NoticesInCategoryViewModel::class) + abstract fun bindsNoticesInCategoryViewModel(viewModel: NoticesInCategoryViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(NotificationPreferencesViewModel::class) + abstract fun bindsNotificationPreferencesViewModel(viewModel: NotificationPreferencesViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(CustomerServiceViewModel::class) + abstract fun bindsCustomerServiceViewModel(viewModel:CustomerServiceViewModel): ViewModel + +} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/CategorizedNoficiation.kt b/feature/main/src/main/java/com/doyoonkim/main/home/HomeScreen.kt similarity index 73% rename from app/src/main/java/com/doyoonkim/knutice/presentation/CategorizedNoficiation.kt rename to feature/main/src/main/java/com/doyoonkim/main/home/HomeScreen.kt index 1ba6620b..a20af81e 100644 --- a/app/src/main/java/com/doyoonkim/knutice/presentation/CategorizedNoficiation.kt +++ b/feature/main/src/main/java/com/doyoonkim/main/home/HomeScreen.kt @@ -1,13 +1,13 @@ -package com.doyoonkim.knutice.presentation +package com.doyoonkim.main.home -import android.content.res.Configuration -import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState @@ -16,6 +16,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -23,28 +24,28 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel -import com.doyoonkim.knutice.model.Destination -import com.doyoonkim.knutice.model.Notice -import com.doyoonkim.knutice.presentation.component.NotificationPreviewCard -import com.doyoonkim.knutice.ui.theme.notificationType1 -import com.doyoonkim.knutice.ui.theme.notificationType2 -import com.doyoonkim.knutice.ui.theme.notificationType3 -import com.doyoonkim.knutice.ui.theme.notificationType4 -import com.doyoonkim.knutice.ui.theme.subTitle -import com.doyoonkim.knutice.viewModel.CategorizedNotificationViewModel -import com.doyoonkim.knutice.R +import com.doyoonkim.common.theme.notificationType1 +import com.doyoonkim.common.theme.notificationType2 +import com.doyoonkim.common.theme.notificationType3 +import com.doyoonkim.common.theme.notificationType4 +import com.doyoonkim.common.theme.subTitle +import com.doyoonkim.common.R +import com.doyoonkim.common.navigation.Destination +import com.doyoonkim.common.ui.NotificationPreviewCard +import com.doyoonkim.main.viewmodel.HomeViewModel +import com.doyoonkim.model.NoticeVO @Composable -fun CategorizedNotification( +fun HomeScreen( modifier: Modifier = Modifier, - viewModel: CategorizedNotificationViewModel = hiltViewModel(), - onGoBackAction: () -> Boolean, + viewModel: HomeViewModel, + bottomPadding: Dp = 0.dp, + onGoBackAction: () -> Unit, onMoreNoticeRequested: (Destination) -> Unit, - onFullContentRequested: (Notice) -> Unit + onFullContentRequested: (Int, String) -> Unit ) { val uiState by viewModel.uiState.collectAsState() @@ -53,6 +54,10 @@ fun CategorizedNotification( onGoBackAction() } + LaunchedEffect(Unit) { + viewModel.getTopThreeNotices() + } + Column( modifier = modifier.verticalScroll( rememberScrollState(0) @@ -63,38 +68,44 @@ fun CategorizedNotification( NotificationPreviewList ( listTitle = stringResource(R.string.general_news), titleColor = MaterialTheme.colorScheme.notificationType1, + isContentLoading = uiState.isLoading, contents = uiState.notificationGeneral, onMoreClicked = { onMoreNoticeRequested(Destination.MORE_GENERAL) } ) { - onFullContentRequested(it) + onFullContentRequested(it.nttId, it.url) } NotificationPreviewList( listTitle = stringResource(R.string.academic_news), titleColor = MaterialTheme.colorScheme.notificationType2, + isContentLoading = uiState.isLoading, contents = uiState.notificationAcademic, onMoreClicked = { onMoreNoticeRequested(Destination.MORE_ACADEMIC) } ) { - onFullContentRequested(it) + onFullContentRequested(it.nttId, it.url) } NotificationPreviewList( listTitle = stringResource(R.string.scholarship_news), titleColor = MaterialTheme.colorScheme.notificationType3, + isContentLoading = uiState.isLoading, contents = uiState.notificationScholarship, onMoreClicked = { onMoreNoticeRequested(Destination.MORE_SCHOLARSHIP) } ) { - onFullContentRequested(it) + onFullContentRequested(it.nttId, it.url) } NotificationPreviewList( listTitle = stringResource(R.string.event_news), titleColor = MaterialTheme.colorScheme.notificationType4, + isContentLoading = uiState.isLoading, contents = uiState.notificationEvent, onMoreClicked = { onMoreNoticeRequested(Destination.MORE_EVENT) } ) { - onFullContentRequested(it) + onFullContentRequested(it.nttId, it.url) } + + Spacer(Modifier.height(bottomPadding)) } } @@ -103,9 +114,10 @@ fun NotificationPreviewList( modifier: Modifier = Modifier, listTitle: String = "List Title goes here", titleColor: Color = Color.Unspecified, - contents: List = listOf(), + isContentLoading: Boolean = false, + contents: List = listOf(), onMoreClicked: () -> Unit = { }, - onNoticeClicked: (Notice) -> Unit + onNoticeClicked: (NoticeVO) -> Unit ) { Column( modifier = Modifier.fillMaxWidth() @@ -114,10 +126,7 @@ fun NotificationPreviewList( horizontalAlignment = Alignment.CenterHorizontally ) { Row( - Modifier.fillMaxWidth( - - ) - .wrapContentHeight(), + Modifier.fillMaxWidth().wrapContentHeight(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { @@ -145,20 +154,11 @@ fun NotificationPreviewList( contents.forEach { content -> NotificationPreviewCard( notificationTitle = content.title, - notificationInfo = "[${content.departName}] ${content.timestamp}" + notificationInfo = "[${content.departName}] ${content.timestamp}", + isLoading = isContentLoading ) { onNoticeClicked(content) } } } -} - -@Composable -@Preview(showBackground = true, showSystemUi = false, uiMode = Configuration.UI_MODE_NIGHT_YES) -fun CategorizedNotification_Preview() { - NotificationPreviewList( - contents = listOf(Notice(), Notice(), Notice()) - ) { - - } } \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/DetailedNoticeContent.kt b/feature/main/src/main/java/com/doyoonkim/main/notice/NoticeDetailScreen.kt similarity index 64% rename from app/src/main/java/com/doyoonkim/knutice/presentation/DetailedNoticeContent.kt rename to feature/main/src/main/java/com/doyoonkim/main/notice/NoticeDetailScreen.kt index 8d5474e4..a821da9d 100644 --- a/app/src/main/java/com/doyoonkim/knutice/presentation/DetailedNoticeContent.kt +++ b/feature/main/src/main/java/com/doyoonkim/main/notice/NoticeDetailScreen.kt @@ -1,10 +1,9 @@ -package com.doyoonkim.knutice.presentation +package com.doyoonkim.main.notice import android.app.DownloadManager import android.content.Context.DOWNLOAD_SERVICE import android.content.Intent import android.content.res.Configuration -import android.net.Uri import android.os.Environment import android.util.Log import android.view.View @@ -16,56 +15,59 @@ import android.webkit.WebView import android.webkit.WebViewClient import android.widget.Toast import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Snackbar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.doyoonkim.knutice.R -import com.doyoonkim.knutice.model.Notice -import com.doyoonkim.knutice.ui.theme.displayBackground -import com.doyoonkim.knutice.viewModel.DetailedNoticeContentViewModel -import okio.Path.Companion.toPath import androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.doyoonkim.main.viewmodel.NoticeDetailViewModel +import com.doyoonkim.model.NoticeVO +import com.doyoonkim.common.R +import com.doyoonkim.common.theme.subTitle +import com.doyoonkim.common.theme.textPurple @Composable -fun DetailedNoticeContent( +fun NoticeDetailScreen( modifier: Modifier = Modifier, - viewModel: DetailedNoticeContentViewModel = hiltViewModel(), + viewModel: NoticeDetailViewModel, + noticeInfo: Triple, + onBookmarkCreate: (NoticeVO) -> Unit, onBackPressed: () -> Unit ) { - val state by viewModel.uiState.collectAsStateWithLifecycle() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() BackHandler { onBackPressed() } - Column( - modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.displayBackground), - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally - ) { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth().wrapContentHeight(), - progress = { - state.loadingStatue - } - ) + LaunchedEffect(uiState.isReceived) { + if (!uiState.isReceived) viewModel.getTargetNoticeById(noticeInfo.first) + } - if (state.url.isNotBlank() || state.requestedNotice.url != "Unknown") { + if (noticeInfo.second.isNotBlank()) { + Box( + modifier = modifier + ) { AndroidView( - modifier = Modifier, + modifier = Modifier.fillMaxSize(), factory = { context -> WebView(context).apply { //Enable Javascript @@ -142,21 +144,63 @@ fun DetailedNoticeContent( visibility = View.INVISIBLE settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW - if (state.url != "Unknown") { - loadUrl(state.url) - } else { - loadUrl(state.requestedNotice.url) - } + loadUrl(noticeInfo.second) } } ) + + if (noticeInfo.third) { + FloatingActionButton( + modifier = Modifier.wrapContentSize() + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)) + .padding(end = 10.dp, bottom = 30.dp) + .align(Alignment.BottomEnd), + onClick = { + if (uiState.isReceived) uiState.receivedNotice?.let(onBookmarkCreate) + }, + containerColor = if (uiState.isReceived) { + MaterialTheme.colorScheme.textPurple + } else { + MaterialTheme.colorScheme.subTitle + } + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = "Add to bookmark", + tint = Color.White + ) + } + } } } } - - -@Preview +@Preview(showSystemUi = true, showBackground = true) @Composable -fun DetailedNoticeContent_Preview() { +fun NoticeDetailScreen_Preview() { + Box( + modifier = Modifier.fillMaxSize() + ) { + + FloatingActionButton( + modifier = Modifier.wrapContentSize() + .padding(end = 10.dp, bottom = 30.dp) + .align(Alignment.BottomEnd), + onClick = { + // TODO Set proper paramter for onBookmarkCreate() +// if (true) uiState.receivedNotice?.let() + }, + containerColor = if (true) { + MaterialTheme.colorScheme.textPurple + } else { + MaterialTheme.colorScheme.subTitle + } + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = "Add to bookmark", + tint = Color.White + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/SearchNoticeComposable.kt b/feature/main/src/main/java/com/doyoonkim/main/notice/NoticeSearchScreen.kt similarity index 65% rename from app/src/main/java/com/doyoonkim/knutice/presentation/SearchNoticeComposable.kt rename to feature/main/src/main/java/com/doyoonkim/main/notice/NoticeSearchScreen.kt index ab1c114b..0dac7b72 100644 --- a/app/src/main/java/com/doyoonkim/knutice/presentation/SearchNoticeComposable.kt +++ b/feature/main/src/main/java/com/doyoonkim/main/notice/NoticeSearchScreen.kt @@ -1,6 +1,5 @@ -package com.doyoonkim.knutice.presentation +package com.doyoonkim.main.notice -import android.content.res.Configuration import androidx.activity.compose.BackHandler import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut @@ -27,51 +26,34 @@ import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.doyoonkim.knutice.R -import com.doyoonkim.knutice.model.Notice -import com.doyoonkim.knutice.presentation.component.NotificationPreview -import com.doyoonkim.knutice.ui.theme.containerBackground -import com.doyoonkim.knutice.ui.theme.subTitle -import com.doyoonkim.knutice.ui.theme.textPurple -import com.doyoonkim.knutice.ui.theme.title -import com.doyoonkim.knutice.viewModel.SearchNoticeViewModel -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter +import com.doyoonkim.main.viewmodel.NoticeSearchViewModel +import com.doyoonkim.common.R +import com.doyoonkim.common.theme.containerBackground +import com.doyoonkim.common.theme.subTitle +import com.doyoonkim.common.theme.textPurple +import com.doyoonkim.common.theme.title +import com.doyoonkim.common.ui.NotificationPreview -@OptIn(FlowPreview::class) @Composable -fun SearchNotice( +fun NoticeSearchScreen( modifier: Modifier = Modifier, - viewModel: SearchNoticeViewModel = hiltViewModel(), - onBackClicked: () -> Unit = { }, - onNoticeClicked: (Notice) -> Unit + viewModel: NoticeSearchViewModel, + onBackPressed: () -> Unit, + onNoticeSelected: (Int, String) -> Unit ) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - snapshotFlow{ uiState.searchKeyword } - .debounce(500L) - .distinctUntilChanged() - .filter { it.isNotBlank() } - .collectLatest { - viewModel.queryNoticeByKeyword(it) - } - } + BackHandler { onBackPressed() } - BackHandler { onBackClicked() } + LaunchedEffect(uiState.searchKeyword) { + viewModel.observeKeywordInput() + } Column( modifier = modifier @@ -92,7 +74,7 @@ fun SearchNotice( .padding(2.dp), value = uiState.searchKeyword, placeholder = { Text(stringResource(R.string.title_search)) }, - onValueChange = { viewModel.updateKeyword(it) }, + onValueChange = { viewModel.updateSearchKeyword(it) }, colors = TextFieldDefaults.colors( focusedTextColor = MaterialTheme.colorScheme.title, unfocusedTextColor = MaterialTheme.colorScheme.subTitle, @@ -107,59 +89,51 @@ fun SearchNotice( } Box( - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxSize() .align(Alignment.CenterHorizontally) ) { LazyColumn( - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .wrapContentHeight(), contentPadding = PaddingValues(3.dp) ) { - items(uiState.queryResult) { notice -> + items(uiState.fetchResult) { notice -> HorizontalDivider( Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp), color = MaterialTheme.colorScheme.containerBackground ) Row( - modifier = Modifier.fillMaxWidth().wrapContentHeight() - .clickable { - onNoticeClicked(notice) - } + modifier = Modifier + .fillMaxWidth() + .clickable { onNoticeSelected(notice.nttId, notice.url) } ) { NotificationPreview( modifier = Modifier.fillMaxWidth(), - isImageContained = notice.imageUrl != "Unknown", + isLoading = uiState.isFetching, + isImageContained = notice.imageUrl != null, notificationTitle = notice.title, notificationInfo = "[${notice.departName}] ${notice.timestamp}", - imageUrl = notice.imageUrl + imageUrl = notice.imageUrl ?: "" ) } - } } - // Loading Indicator w/ AnimatedVisibility - androidx.compose.animation.AnimatedVisibility( - visible = uiState.isQuerying, + androidx.compose.animation.AnimatedVisibility( + visible = uiState.isFetching, modifier = Modifier.wrapContentSize().align(Alignment.Center), enter = scaleIn(), - exit = scaleOut(), + exit = scaleOut() ) { CircularProgressIndicator( - color = MaterialTheme.colorScheme.textPurple + color = MaterialTheme.colorScheme.textPurple, + trackColor = MaterialTheme.colorScheme.containerBackground ) } } } -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES.and(Configuration.UI_MODE_NIGHT_MASK), showSystemUi = true) -@Composable -fun SearchNotice_Preview( -) { - SearchNotice { - - } } \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/MoreCategorizedNoticiation.kt b/feature/main/src/main/java/com/doyoonkim/main/notice/NoticesInCategoryScreen.kt similarity index 62% rename from app/src/main/java/com/doyoonkim/knutice/presentation/MoreCategorizedNoticiation.kt rename to feature/main/src/main/java/com/doyoonkim/main/notice/NoticesInCategoryScreen.kt index 50ac36f6..20c10a69 100644 --- a/app/src/main/java/com/doyoonkim/knutice/presentation/MoreCategorizedNoticiation.kt +++ b/feature/main/src/main/java/com/doyoonkim/main/notice/NoticesInCategoryScreen.kt @@ -1,6 +1,5 @@ -package com.doyoonkim.knutice.presentation +package com.doyoonkim.main.notice -import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -12,13 +11,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn -//noinspection UsingMaterialAndMaterial3Libraries -import androidx.compose.material.Divider import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -26,34 +24,38 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.doyoonkim.knutice.model.Notice -import com.doyoonkim.knutice.presentation.component.NotificationPreview -import com.doyoonkim.knutice.ui.theme.containerBackground -import com.doyoonkim.knutice.ui.theme.containerBackgroundSolid -import com.doyoonkim.knutice.ui.theme.subTitle -import com.doyoonkim.knutice.viewModel.MoreCategorizedNotificationViewModel +import com.doyoonkim.common.theme.containerBackground +import com.doyoonkim.common.theme.containerBackgroundSolid +import com.doyoonkim.common.theme.textPurple +import com.doyoonkim.common.ui.NotificationPreview +import com.doyoonkim.main.viewmodel.NoticesInCategoryViewModel +import com.doyoonkim.model.NoticeCategory @OptIn(ExperimentalMaterialApi::class) @Composable -fun MoreCategorizedNotification( - modifier: Modifier = Modifier, - viewModel: MoreCategorizedNotificationViewModel = hiltViewModel(), - backButtonHandler: () -> Unit = { }, - onNoticeSelected: (Notice) -> Unit = { } +fun NoticesInCategoryScreen( + modifier: Modifier, + category: NoticeCategory = NoticeCategory.Unspecified, + viewModel: NoticesInCategoryViewModel, + onBackButtonPressed: () -> Unit = { }, + onNoticeSelected: (Int, String) -> Unit ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + // Back Handler + BackHandler { onBackButtonPressed() } + + // Pull-to-Refresh val pullRefreshState = rememberPullRefreshState( refreshing = uiState.isRefreshRequested, - onRefresh = { - viewModel.requestRefresh() - } + onRefresh = { viewModel.requestRefresh() } ) - BackHandler { - backButtonHandler() + // Fetching notification on entry + LaunchedEffect(uiState.isNoticesRequested, uiState.isRefreshRequested) { + if (uiState.isRefreshRequested || uiState.isNoticesRequested) + viewModel.getNoticesPerPageInCategory(category) } Box( @@ -61,12 +63,6 @@ fun MoreCategorizedNotification( .background(MaterialTheme.colorScheme.containerBackground) .pullRefresh(pullRefreshState) ) { - LaunchedEffect(Unit) { - Log.d("MoreCategorizedNotification", "Initialize Notice Category to be fetched") -// if (uiState.currentLastNttId == 0) -// viewModel.setNotificationCategory(category) - viewModel.fetchNotificationPerPage() - } LazyColumn( Modifier.fillMaxWidth().wrapContentHeight(), verticalArrangement = Arrangement.spacedBy(5.dp), @@ -75,40 +71,38 @@ fun MoreCategorizedNotification( items(uiState.notices.size) { index -> if (index == uiState.notices.size - 1) { Row( - modifier = Modifier.fillMaxWidth() - .wrapContentHeight(), + modifier = Modifier.fillMaxWidth().wrapContentHeight(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { CircularProgressIndicator( modifier = Modifier.wrapContentSize(), - color = MaterialTheme.colorScheme.subTitle + color = MaterialTheme.colorScheme.textPurple, + trackColor = MaterialTheme.colorScheme.containerBackground ) } viewModel.requestMoreNotices() } else { - val notice = uiState.notices[index] - if (index != 0 || !uiState.isLoading) { - Divider( - Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp), - color = MaterialTheme.colorScheme.containerBackgroundSolid, - thickness = 1.3.dp + if (index != 0) { + HorizontalDivider( + Modifier.fillMaxWidth(), + color =MaterialTheme.colorScheme.containerBackgroundSolid, + thickness = 1.2.dp ) } + val notice = uiState.notices[index] Row( modifier = Modifier.wrapContentSize() - .clickable { - onNoticeSelected(notice) - } + .clickable { onNoticeSelected(notice.nttId, notice.url) } ) { NotificationPreview( + isLoading = uiState.isLoading, notificationTitle = notice.title, notificationInfo = "[${notice.departName}] ${notice.timestamp}", - isImageContained = notice.imageUrl != "Unknown", - imageUrl = notice.imageUrl + isImageContained = notice.imageUrl != null, + imageUrl = notice.imageUrl ?: "" ) } - } } } @@ -119,5 +113,4 @@ fun MoreCategorizedNotification( state = pullRefreshState ) } -} - +} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/CustomerService.kt b/feature/main/src/main/java/com/doyoonkim/main/preference/CustomerServiceScreen.kt similarity index 78% rename from app/src/main/java/com/doyoonkim/knutice/presentation/CustomerService.kt rename to feature/main/src/main/java/com/doyoonkim/main/preference/CustomerServiceScreen.kt index cd628425..ddce10dd 100644 --- a/app/src/main/java/com/doyoonkim/knutice/presentation/CustomerService.kt +++ b/feature/main/src/main/java/com/doyoonkim/main/preference/CustomerServiceScreen.kt @@ -1,5 +1,6 @@ -package com.doyoonkim.knutice.presentation +package com.doyoonkim.main.preference +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut @@ -7,25 +8,29 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -33,30 +38,37 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel -import com.doyoonkim.knutice.R -import com.doyoonkim.knutice.ui.theme.containerBackground -import com.doyoonkim.knutice.ui.theme.subTitle -import com.doyoonkim.knutice.ui.theme.textPurple -import com.doyoonkim.knutice.ui.theme.title -import com.doyoonkim.knutice.viewModel.CustomerServiceViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.doyoonkim.main.viewmodel.CustomerServiceViewModel +import com.doyoonkim.common.R +import com.doyoonkim.common.theme.buttonContainer +import com.doyoonkim.common.theme.buttonPurple +import com.doyoonkim.common.theme.containerBackground +import com.doyoonkim.common.theme.subTitle +import com.doyoonkim.common.theme.textPurple +import com.doyoonkim.common.theme.title @Composable -fun CustomerService( - modifier: Modifier = Modifier, - viewModel: CustomerServiceViewModel = hiltViewModel(), - onSubmitRequested: (String) -> Unit = {}, - onCloseRequested: () -> Unit = {} +fun CustomerServiceScreen( + modifier: Modifier = Modifier, + viewModel: CustomerServiceViewModel, + onBackPressed: () -> Unit ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + // Version Information for Report Submission + val versionInfo = stringResource(R.string.version_code) val adjustImePadding = Modifier.consumeWindowInsets(WindowInsets.ime).imePadding() + + BackHandler { + onBackPressed() + } + Box( modifier = modifier.fillMaxSize() - .windowInsetsPadding(WindowInsets.systemBars) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)) ) { Column( verticalArrangement = Arrangement.Top, @@ -115,10 +127,18 @@ fun CustomerService( } Button( - modifier = Modifier.fillMaxWidth().wrapContentHeight(), + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(start = 3.dp, end = 3.dp) + , enabled = !uiState.isSubmissionCompleted && uiState.exceedMinCharacters, shape = RoundedCornerShape(10.dp), - onClick = { viewModel.submitUserReport() } + colors = ButtonDefaults.buttonColors().copy( + containerColor = MaterialTheme.colorScheme.buttonPurple, + contentColor = Color.White, + ), + onClick = { viewModel.submitUserReport(versionInfo) } ) { Text( text = stringResource(R.string.btn_submit), @@ -156,7 +176,7 @@ fun CustomerService( text = stringResource(R.string.submission_completed__subtitle) ) Button( - onClick = { viewModel.updateCompletionState() }, + onClick = { viewModel.resetSubmissionStatus() }, modifier = Modifier.fillMaxWidth() ) { Text( @@ -171,10 +191,4 @@ fun CustomerService( } } -} - -@Composable -@Preview(showSystemUi = true, locale = "ko-rKR",) -fun CustomerService_Preview() { - } \ No newline at end of file diff --git a/feature/main/src/main/java/com/doyoonkim/main/preference/NotificationPreferencesScreen.kt b/feature/main/src/main/java/com/doyoonkim/main/preference/NotificationPreferencesScreen.kt new file mode 100644 index 00000000..cd52c3a4 --- /dev/null +++ b/feature/main/src/main/java/com/doyoonkim/main/preference/NotificationPreferencesScreen.kt @@ -0,0 +1,207 @@ +package com.doyoonkim.main.preference + +import android.Manifest +import android.app.NotificationManager +import android.content.Context +import android.content.Context.NOTIFICATION_SERVICE +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Paint.Align +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.Text +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.doyoonkim.common.R +import com.doyoonkim.common.theme.buttonPurple +import com.doyoonkim.common.theme.subTitle +import com.doyoonkim.common.theme.title +import com.doyoonkim.common.ui.LabeledToggleSwitch +import com.doyoonkim.main.viewmodel.NotificationPreferencesViewModel + +@Composable +fun NotificationPreferencesScreen( + modifier: Modifier = Modifier, + viewModel: NotificationPreferencesViewModel, + onBackPressed: () -> Unit +) { + // Context for main status checking + val context = LocalContext.current + val uiStatus by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + isMainNotificationPermissionGranted( + context, + onResult = { viewModel.updateMainNotificationPermissionStatus(it) } + ) + viewModel.getTopicSubscriptionStatus() + } + + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + // Once user leave the application for permission settings and getting back. + isMainNotificationPermissionGranted( + context, + onResult = { viewModel.updateMainNotificationPermissionStatus(it) } + ) + } + + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + Text( + modifier = Modifier.fillMaxWidth().wrapContentHeight(), + text = stringResource(R.string.pref_notification_title), + color = MaterialTheme.colorScheme.title, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + textAlign = TextAlign.Start + ) + + HorizontalDivider( + Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.subTitle + ) + + Column( + modifier = Modifier.wrapContentHeight() + .padding(top = 15.dp, bottom = 15.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.wrapContentHeight().weight(5f), + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + Text( + modifier = Modifier.fillMaxWidth().wrapContentHeight(), + text = stringResource(R.string.enable_notification_title), + color = MaterialTheme.colorScheme.title, + fontWeight = FontWeight.Medium, + fontSize = 18.sp, + textAlign = TextAlign.Start + ) + + Text( + modifier = Modifier.fillMaxWidth().wrapContentHeight(), + text = stringResource(R.string.enable_service_notification_sub), + color = MaterialTheme.colorScheme.subTitle, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + textAlign = TextAlign.Start + ) + } + + Switch( + checked = uiStatus.isMainNotificationPermissionGranted, + colors = SwitchDefaults.colors().copy( + checkedTrackColor = MaterialTheme.colorScheme.buttonPurple, + checkedThumbColor = Color.White + ), + onCheckedChange = { + val settingIntent = Intent( + "android.settings.APP_NOTIFICATION_SETTINGS" + ).apply { + this.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + this.putExtra( + "android.provider.extra.APP_PACKAGE", + context.packageName + ) + } + context.startActivity(settingIntent) + }, + enabled = true + ) + } + } + + LabeledToggleSwitch( + modifier = Modifier.padding(start = 10.dp), + titleText = stringResource(R.string.general_notificaiton_channel_name), + subTitleText = stringResource(R.string.general_notification_channel_description), + isChecked = uiStatus.isEachChannelAllowed[0], + isEnabled = uiStatus.isMainNotificationPermissionGranted + ) { + viewModel.updateChannelPreferenceState(0, it) + } + + LabeledToggleSwitch( + modifier = Modifier.padding(start = 10.dp), + titleText = stringResource(R.string.academic_notification_channel_name), + subTitleText = stringResource(R.string.academic_notification_channel_description), + isChecked = uiStatus.isEachChannelAllowed[1], + isEnabled = uiStatus.isMainNotificationPermissionGranted + ) { + viewModel.updateChannelPreferenceState(1, it) + } + + LabeledToggleSwitch( + modifier = Modifier.padding(start = 10.dp), + titleText = stringResource(R.string.scholarship_notification_channel_name), + subTitleText = stringResource(R.string.scholarship_notification_channel_description), + isChecked = uiStatus.isEachChannelAllowed[2], + isEnabled = uiStatus.isMainNotificationPermissionGranted + ) { + viewModel.updateChannelPreferenceState(2, it) + } + + LabeledToggleSwitch( + modifier = Modifier.padding(start = 10.dp), + titleText = stringResource(R.string.event_notification_channel_name), + subTitleText = stringResource(R.string.event_notification_channel_description), + isChecked = uiStatus.isEachChannelAllowed[3], + isEnabled = uiStatus.isMainNotificationPermissionGranted + ) { + viewModel.updateChannelPreferenceState(3, it) + } + + } + +} + +fun isMainNotificationPermissionGranted( + context: Context, + onResult: (Boolean) -> Unit +) { + val isNotificationAllowed = ContextCompat.checkSelfPermission( + context, Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + + val isChannelAllowed = (context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager) + .getNotificationChannel(context.getString(R.string.inapp_notification_channel_id)) + .importance > 0 + + if (isNotificationAllowed && isChannelAllowed) onResult(true) + else onResult(false) +} diff --git a/feature/main/src/main/java/com/doyoonkim/main/preference/OssNoticeScreen.kt b/feature/main/src/main/java/com/doyoonkim/main/preference/OssNoticeScreen.kt new file mode 100644 index 00000000..3f923c8d --- /dev/null +++ b/feature/main/src/main/java/com/doyoonkim/main/preference/OssNoticeScreen.kt @@ -0,0 +1,24 @@ +package com.doyoonkim.main.preference + +import android.webkit.WebView +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView + +@Composable +fun OssNoticeScreen( + modifier: Modifier = Modifier, + onBackPressed: () -> Unit +) { + BackHandler { onBackPressed() } + + AndroidView( + modifier = modifier, + factory = { context -> + WebView(context).apply { + loadUrl("https://knutice.github.io/KNUTICE-OpenSourceLicense/Android/opensource.html") + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/UserPreference.kt b/feature/main/src/main/java/com/doyoonkim/main/preference/UserPreferencesScreen.kt similarity index 80% rename from app/src/main/java/com/doyoonkim/knutice/presentation/UserPreference.kt rename to feature/main/src/main/java/com/doyoonkim/main/preference/UserPreferencesScreen.kt index 08dc255c..68ace30c 100644 --- a/app/src/main/java/com/doyoonkim/knutice/presentation/UserPreference.kt +++ b/feature/main/src/main/java/com/doyoonkim/main/preference/UserPreferencesScreen.kt @@ -1,8 +1,7 @@ -package com.doyoonkim.knutice.presentation +package com.doyoonkim.main.preference import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -25,20 +24,17 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.doyoonkim.knutice.model.Destination -import com.doyoonkim.knutice.ui.theme.buttonContainer -import com.doyoonkim.knutice.ui.theme.subTitle -import com.doyoonkim.knutice.R -import com.doyoonkim.knutice.ui.theme.title - -// TODO: Apply Color Theme on HorizontalDivider. +import com.doyoonkim.common.R +import com.doyoonkim.common.theme.buttonContainer +import com.doyoonkim.common.theme.subTitle +import com.doyoonkim.common.theme.title @Composable -fun UserPreference( +fun UserPreferenceScreen( modifier: Modifier = Modifier, - onNotificationPreferenceClicked: (Destination) -> Unit, - onCustomerServiceClicked: (Destination) -> Unit, - onOssClicked: (Destination) -> Unit, + onNotificationPreferenceClicked: () -> Unit, + onCustomerServiceClicked: () -> Unit, + onOssClicked: () -> Unit, onBackPressed: () -> Unit, ) { BackHandler { onBackPressed() } @@ -49,7 +45,8 @@ fun UserPreference( verticalArrangement = Arrangement.Top ) { Text( - modifier = Modifier.fillMaxWidth().wrapContentHeight(), + modifier = Modifier.fillMaxWidth().wrapContentHeight() + .padding(bottom = 2.dp), text = stringResource(R.string.pref_notification_title), fontWeight = FontWeight.SemiBold, fontSize = 14.sp, @@ -63,11 +60,7 @@ fun UserPreference( ) Column( - modifier = Modifier.fillMaxWidth().wrapContentSize() - .padding(top = 15.dp, bottom = 15.dp) - .clickable { - onNotificationPreferenceClicked(Destination.NOTIFICATION) - }, + modifier = Modifier.fillMaxWidth().wrapContentSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { @@ -75,13 +68,23 @@ fun UserPreference( verticalAlignment = Alignment.CenterVertically ) { Text( - modifier = Modifier.fillMaxWidth().wrapContentHeight(), + modifier = Modifier.wrapContentHeight().weight(5f), text = stringResource(R.string.enable_notification_title), fontWeight = FontWeight.Medium, fontSize = 18.sp, textAlign = TextAlign.Start, color = MaterialTheme.colorScheme.title ) + + IconButton( + onClick = { onNotificationPreferenceClicked() } + ) { + Image( + painter = painterResource(R.drawable.baseline_arrow_forward_ios_24), + contentDescription = "Go to App Notification Preferences page.", + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.buttonContainer) + ) + } } } @@ -92,7 +95,7 @@ fun UserPreference( Text( modifier = Modifier.fillMaxWidth().wrapContentHeight() - .padding(top = 20.dp), + .padding(top = 20.dp, bottom = 2.dp), text = stringResource(R.string.title_support), fontWeight = FontWeight.SemiBold, fontSize = 14.sp, @@ -106,8 +109,7 @@ fun UserPreference( ) Row( - modifier = Modifier.fillMaxWidth().wrapContentSize() - .padding(top = 5.dp, bottom = 5.dp), + modifier = Modifier.fillMaxWidth().wrapContentSize(), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -120,7 +122,7 @@ fun UserPreference( ) IconButton( - onClick = { onCustomerServiceClicked(Destination.CS) } + onClick = { onCustomerServiceClicked() } ) { Image( painter = painterResource(R.drawable.baseline_arrow_forward_ios_24), @@ -137,7 +139,7 @@ fun UserPreference( Text( modifier = Modifier.fillMaxWidth().wrapContentHeight() - .padding(top = 20.dp), + .padding(top = 20.dp, bottom = 2.dp), text = stringResource(R.string.about_title), fontWeight = FontWeight.SemiBold, fontSize = 14.sp, @@ -152,7 +154,7 @@ fun UserPreference( Row( modifier = Modifier.fillMaxWidth().wrapContentSize() - .padding(top = 15.dp, bottom = 15.dp), + .padding(top = 12.5.dp, bottom = 12.5.dp), verticalAlignment = Alignment.CenterVertically ) { Text( @@ -168,7 +170,7 @@ fun UserPreference( modifier = Modifier.wrapContentHeight().weight(3f), text = stringResource(R.string.version_code), fontWeight = FontWeight.Normal, - fontSize = 14.sp, + fontSize = 16.sp, textAlign = TextAlign.End, color = MaterialTheme.colorScheme.title ) @@ -180,8 +182,7 @@ fun UserPreference( ) Row( - modifier = Modifier.fillMaxWidth().wrapContentSize() - .padding(top = 5.dp, bottom = 5.dp), + modifier = Modifier.fillMaxWidth().wrapContentSize(), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -194,7 +195,7 @@ fun UserPreference( ) IconButton( - onClick = { onOssClicked(Destination.OSS) } + onClick = { onOssClicked() } ) { Image( painter = painterResource(R.drawable.baseline_arrow_forward_ios_24), @@ -212,9 +213,14 @@ fun UserPreference( } } - -@Preview(showSystemUi = true, locale = "KO") +@Preview(showBackground = true, showSystemUi = true) @Composable -fun UserPreference_Preview() { - +fun UserPreferencesScreen_Preview() { + UserPreferenceScreen( + modifier = Modifier.fillMaxWidth().padding(10.dp), + onNotificationPreferenceClicked = { }, + onCustomerServiceClicked = { }, + onOssClicked = { }, + onBackPressed = { } + ) } \ No newline at end of file diff --git a/feature/main/src/main/java/com/doyoonkim/main/viewmodel/CustomerServiceViewModel.kt b/feature/main/src/main/java/com/doyoonkim/main/viewmodel/CustomerServiceViewModel.kt new file mode 100644 index 00000000..b64649a4 --- /dev/null +++ b/feature/main/src/main/java/com/doyoonkim/main/viewmodel/CustomerServiceViewModel.kt @@ -0,0 +1,77 @@ +package com.doyoonkim.main.viewmodel + +import android.os.Build +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.doyoonkim.domain.usecases.SubmitUserReportImpl +import com.doyoonkim.model.requestBody.UserReportBody +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +class CustomerServiceViewModel @Inject constructor( + private val submitUserReport: SubmitUserReportImpl +) : ViewModel() { + + private var _uiState = MutableStateFlow(CustomerServiceStatus()) + val uiState = _uiState.asStateFlow() + + fun updateUserReportContent(content: String) = + _uiState.update { + it.copy( + userReport = content, + exceedMinCharacters = content.length >= 5, + reachedMaxCharacters = content.length >= 500 + ) + } + + fun submitUserReport(versionInfo: String) = + viewModelScope.launch { + submitUserReport( + UserReportBody( + content = uiState.value.userReport, + deviceName = "${Build.BRAND} ${Build.MODEL}", + version = versionInfo + ) + ).flowOn(Dispatchers.IO) + .collectLatest { result -> + _uiState.update { + if (result) { + it.copy( + userReport = "", + reachedMaxCharacters = false, + isSubmissionCompleted = true, + isSubmissionFailed = false + ) + } else { + it.copy( + isSubmissionFailed = true, + isSubmissionCompleted = true + ) + } + } + } + } + + fun resetSubmissionStatus() = + _uiState.update { + it.copy( + isSubmissionFailed = false, + isSubmissionCompleted = false + ) + } + +} + +data class CustomerServiceStatus( + val userReport: String = "", + val reachedMaxCharacters: Boolean = false, + val exceedMinCharacters: Boolean = false, + val isSubmissionFailed: Boolean = false, + val isSubmissionCompleted: Boolean = false +) \ No newline at end of file diff --git a/feature/main/src/main/java/com/doyoonkim/main/viewmodel/HomeViewModel.kt b/feature/main/src/main/java/com/doyoonkim/main/viewmodel/HomeViewModel.kt new file mode 100644 index 00000000..689dae5f --- /dev/null +++ b/feature/main/src/main/java/com/doyoonkim/main/viewmodel/HomeViewModel.kt @@ -0,0 +1,49 @@ +package com.doyoonkim.main.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.doyoonkim.domain.usecases.FetchTopThreeNoticesImpl +import com.doyoonkim.model.NoticeVO +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +//import javax.inject.Inject + + +class HomeViewModel @Inject constructor( + private val fetchTopThreeNotices: FetchTopThreeNoticesImpl +) : ViewModel() { + + private var _uiState = MutableStateFlow(HomeViewState()) + val uiState = _uiState.asStateFlow() + + fun getTopThreeNotices() = viewModelScope.launch { + fetchTopThreeNotices() + .flowOn(Dispatchers.IO) + .collectLatest { vo -> + _uiState.update { + it.copy( + isLoading = false, + notificationGeneral = vo.general, + notificationScholarship = vo.scholarship, + notificationAcademic = vo.academic, + notificationEvent = vo.event + ) + } + } + } +} + +data class HomeViewState( + val isLoading: Boolean = true, + val notificationGeneral: List = listOf(NoticeVO(), NoticeVO(), NoticeVO()), + val notificationAcademic: List = listOf(NoticeVO(), NoticeVO(), NoticeVO()), + val notificationScholarship: List = listOf(NoticeVO(), NoticeVO(), NoticeVO()), + val notificationEvent: List = listOf(NoticeVO(), NoticeVO(), NoticeVO()) +) \ No newline at end of file diff --git a/feature/main/src/main/java/com/doyoonkim/main/viewmodel/NoticeDetailViewModel.kt b/feature/main/src/main/java/com/doyoonkim/main/viewmodel/NoticeDetailViewModel.kt new file mode 100644 index 00000000..37cd1764 --- /dev/null +++ b/feature/main/src/main/java/com/doyoonkim/main/viewmodel/NoticeDetailViewModel.kt @@ -0,0 +1,53 @@ +package com.doyoonkim.main.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.doyoonkim.domain.usecases.FetchNoticeByIdImpl +import com.doyoonkim.model.NoticeVO +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +class NoticeDetailViewModel @Inject constructor( + private val fetchNoticeById: FetchNoticeByIdImpl +) : ViewModel() { + + private var _uiState = MutableStateFlow(NoticeDetailState()) + val uiState = _uiState.asStateFlow() + + fun getTargetNoticeById(nttId:Int) = + viewModelScope.launch { + fetchNoticeById(nttId) + .flowOn(Dispatchers.IO) + .collectLatest { result -> + _uiState.update { + it.copy( + receivedNotice = result, + isReceived = true + ) + } + } + } + + fun updateLoadingStatus(newStatus: Int) { + viewModelScope.launch { + _uiState.update { + it.copy( + loadingStatus = (newStatus / 100).toFloat() + ) + } + } + } + +} + +data class NoticeDetailState( + val receivedNotice: NoticeVO? = null, + val isReceived: Boolean = false, + val loadingStatus: Float = 0.0f +) \ No newline at end of file diff --git a/feature/main/src/main/java/com/doyoonkim/main/viewmodel/NoticeSearchViewModel.kt b/feature/main/src/main/java/com/doyoonkim/main/viewmodel/NoticeSearchViewModel.kt new file mode 100644 index 00000000..e9b379e6 --- /dev/null +++ b/feature/main/src/main/java/com/doyoonkim/main/viewmodel/NoticeSearchViewModel.kt @@ -0,0 +1,68 @@ +package com.doyoonkim.main.viewmodel + +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.doyoonkim.domain.usecases.FetchNoticesByKeywordImpl +import com.doyoonkim.model.NoticeVO +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +class NoticeSearchViewModel @Inject constructor( + private val fetchNoticesByKeyword: FetchNoticesByKeywordImpl +) : ViewModel() { + + private var _uiState = MutableStateFlow(NoticeSearchState()) + val uiState = _uiState.asStateFlow() + + fun searchNoticeUsingKeyword(keyword: String) { + viewModelScope.launch { + fetchNoticesByKeyword(keyword) + .flowOn(Dispatchers.IO) + .collectLatest { result -> + _uiState.update { + it.copy( + isFetching = false, + fetchResult = result + ) + } + } + } + } + + @OptIn(FlowPreview::class) + suspend fun observeKeywordInput() = snapshotFlow { uiState.value.searchKeyword } + .debounce(500L) + .distinctUntilChanged() + .filter { it.isNotBlank() } + .collectLatest { + searchNoticeUsingKeyword(it) + } + + fun updateSearchKeyword(newKeyword: String) { + _uiState.update { + it.copy( + searchKeyword = newKeyword + ) + } + } + + private fun updateFetchingStatus(status: Boolean) = _uiState.update { it.copy(isFetching = status) } + +} + +data class NoticeSearchState( + val searchKeyword: String = "", + val isFetching: Boolean = false, + val fetchResult: List = emptyList() +) \ No newline at end of file diff --git a/feature/main/src/main/java/com/doyoonkim/main/viewmodel/NoticesInCategoryViewModel.kt b/feature/main/src/main/java/com/doyoonkim/main/viewmodel/NoticesInCategoryViewModel.kt new file mode 100644 index 00000000..c8fad649 --- /dev/null +++ b/feature/main/src/main/java/com/doyoonkim/main/viewmodel/NoticesInCategoryViewModel.kt @@ -0,0 +1,76 @@ +package com.doyoonkim.main.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.doyoonkim.domain.usecases.FetchNoticesPerPageImpl +import com.doyoonkim.model.NoticeCategory +import com.doyoonkim.model.NoticeVO +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +class NoticesInCategoryViewModel @Inject constructor( + private val fetchNoticesPerPage: FetchNoticesPerPageImpl +) : ViewModel() { + + // uiStates + private var _uiState = MutableStateFlow(NoticesInCategoryStates()) + val uiState = _uiState.asStateFlow() + + fun getNoticesPerPageInCategory(category: NoticeCategory) = + viewModelScope.launch { + fetchNoticesPerPage(category, uiState.value.currentLastNttId) + .flowOn(Dispatchers.IO) + .collectLatest { result -> + _uiState.update { + it.copy( + currentLastNttId = result.last().nttId, + notices = + if (uiState.value.currentLastNttId == 0) + result + else + it.notices.addAll(result), + isNoticesRequested = false, + isLoading = false, + isRefreshRequested = false + ) + } + } + } + + fun requestRefresh() = + _uiState.update { + it.copy( + currentLastNttId = 0, + notices = emptyList(), + isRefreshRequested = true + ) + } + + // TODO Subject to be removed. + fun requestMoreNotices() { + // TODO Need to fix the way to pass the NoticeCategory parameter. + if (!uiState.value.isLoading) _uiState.update { it.copy(isNoticesRequested = true) } + } + + + private fun List.addAll(extra: List) = + List(this.size + extra.size) { + if (it < this.size) this[it] + else extra[it - this.size] + } + +} + +data class NoticesInCategoryStates( + val currentLastNttId: Int = 0, + val notices: List = List(20) { NoticeVO() }, + val isNoticesRequested: Boolean = true, + val isLoading: Boolean = true, + val isRefreshRequested: Boolean = false +) \ No newline at end of file diff --git a/feature/main/src/main/java/com/doyoonkim/main/viewmodel/NotificationPreferencesViewModel.kt b/feature/main/src/main/java/com/doyoonkim/main/viewmodel/NotificationPreferencesViewModel.kt new file mode 100644 index 00000000..8e8e9501 --- /dev/null +++ b/feature/main/src/main/java/com/doyoonkim/main/viewmodel/NotificationPreferencesViewModel.kt @@ -0,0 +1,105 @@ +package com.doyoonkim.main.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.doyoonkim.domain.usecases.FetchTopicSubscriptionStatusImpl +import com.doyoonkim.domain.usecases.SubmitNotificationPreferencesImpl +import com.doyoonkim.model.NoticeCategory +import com.doyoonkim.model.requestBody.TopicSubscriptionPreferencesBody +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +class NotificationPreferencesViewModel @Inject constructor( + private val submitNotificationPreferences: SubmitNotificationPreferencesImpl, + private val fetchTopicSubscriptionStatus: FetchTopicSubscriptionStatusImpl +) : ViewModel() { + + private var _uiState = MutableStateFlow(NotificationPreferencesState()) + val uiState = _uiState.asStateFlow() + + private val notificationChannels = hashMapOf( + 0 to NoticeCategory.GENERAL_NEWS, + 1 to NoticeCategory.ACADEMIC_NEWS, + 2 to NoticeCategory.SCHOLARSHIP_NEWS, + 3 to NoticeCategory.EVENT_NEWS + ) + + fun updateMainNotificationPermissionStatus(status: Boolean) = + _uiState.update { + it.copy( + isMainNotificationPermissionGranted = status + ) + } + + fun updateChannelPreferenceState(index: Int, state: Boolean) { + _uiState.update { + it.copy( + isEachChannelAllowed = it.isEachChannelAllowed.updateValueByIndex(index, state) + ) + } + + CoroutineScope(Dispatchers.IO).launch { + val jobSubmit = launch { + // Ignore the result. + submitNotificationPreferences( + TopicSubscriptionPreferencesBody( + noticeName = notificationChannels[index]!!.name, + isSubscribed = state + ) + ).collectLatest { result -> + if (!result) { + _uiState.update { + it.copy( + isEachChannelAllowed = it.isEachChannelAllowed.updateValueByIndex(index, !state), + isError = true + ) + } + } + } + } + delay(5000L) + if (!jobSubmit.isCompleted) jobSubmit.cancelAndJoin() + } + } + + fun getTopicSubscriptionStatus() = + viewModelScope.launch { + fetchTopicSubscriptionStatus() + .flowOn(Dispatchers.IO) + .collectLatest { status -> + _uiState.update { + it.copy( + isEachChannelAllowed = listOf( + status.general, + status.academic, + status.scholarship, + status.event + ) + ) + } + } + } + + private fun List.updateValueByIndex(index: Int, value: Boolean) = + List(this.size) { + if (it == index) value + else this[it] + } + +} + +data class NotificationPreferencesState( + val isMainNotificationPermissionGranted: Boolean = false, + val isEachChannelAllowed: List = listOf(false, false, false, false), + val isSyncCompleted: Boolean = false, + val isError: Boolean = false +) \ No newline at end of file diff --git a/feature/main/src/test/java/com/doyoonkim/main/ExampleUnitTest.kt b/feature/main/src/test/java/com/doyoonkim/main/ExampleUnitTest.kt new file mode 100644 index 00000000..4f2d1718 --- /dev/null +++ b/feature/main/src/test/java/com/doyoonkim/main/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.doyoonkim.main + +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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9f01aaf5..b980c224 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,30 +1,26 @@ [versions] -agp = "8.6.0" +agp = "8.6.1" coilCompose = "2.4.0" -converterGson = "2.3.0" -converterMoshi = "2.9.0" -datastorePreferences = "1.1.1" +converterGson = "2.11.0" +datastorePreferences = "1.1.7" firebaseMessagingDirectboot = "24.0.2" -gson = "2.11.0" -hiltAndroidCompiler = "2.51.1" -hiltNavigationCompose = "1.2.0" jsoup = "1.18.1" -kotlin = "1.9.21" -coreKtx = "1.10.1" +kotlin = "2.1.20" +coreKtx = "1.16.0" junit = "4.13.2" junitVersion = "1.1.5" espressoCore = "3.5.1" -kotlinxCoroutinesAndroid = "1.7.1" +kotlinxCoroutinesAndroid = "1.7.3" kotlinxCoroutinesTest = "1.7.3" -lifecycleRuntimeKtx = "2.6.1" -activityCompose = "1.8.0" -composeBom = "2024.04.01" -lifecycleRuntimeKtxVersion = "2.8.6" -navigationCompose = "2.8.1" +lifecycleRuntimeKtx = "2.9.0" +activityCompose = "1.10.1" +composeBom = "2025.05.01" +lifecycleRuntimeKtxVersion = "2.9.0" +navigationCompose = "2.9.0" retrofit = "2.9.0" -roomCompiler = "2.6.1" -roomKtx = "2.6.1" -roomRuntime = "2.6.1" +roomCompiler = "2.7.1" +roomKtx = "2.7.1" +roomRuntime = "2.7.1" swiperefreshlayout = "1.1.0" googleGmsGoogleServices = "4.4.2" firebaseMessaging = "24.0.2" @@ -32,14 +28,16 @@ kotlinSerialization = "1.6.0" junitKtx = "1.2.1" protoliteWellKnownTypes = "18.0.0" translate = "17.0.3" +appcompat = "1.7.0" +material = "1.12.0" +dagger = "2.56.2" splashScreen = "1.0.1" +jetbrainsKotlinJvm = "2.1.20" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "splashScreen" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } -androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } -androidx-lifecycle-runtime-ktx-v286 = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtxVersion" } androidx-material = { module = "androidx.compose.material:material" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomCompiler" } @@ -48,13 +46,7 @@ androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = " androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swiperefreshlayout" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" } converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } -converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "converterMoshi" } firebase-messaging-directboot = { module = "com.google.firebase:firebase-messaging-directboot", version.ref = "firebaseMessagingDirectboot" } -google-firebase-messaging = { module = "com.google.firebase:firebase-messaging" } -gson = { module = "com.google.code.gson:gson", version.ref = "gson" } -hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroidCompiler" } -hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroidCompiler" } -jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -70,12 +62,20 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit androidx-material3 = { group = "androidx.compose.material3", name = "material3" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesAndroid" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging", version.ref = "firebaseMessaging" } kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref="kotlinSerialization" } androidx-junit-ktx = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "junitKtx" } protolite-well-known-types = { group = "com.google.firebase", name = "protolite-well-known-types", version.ref = "protoliteWellKnownTypes" } translate = { module = "com.google.mlkit:translate", version.ref = "translate" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +dagger = { module = "com.google.dagger:dagger", version.ref = "dagger" } +dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "dagger" } +dagger-android = { module = "com.google.dagger:dagger-android", version.ref = "dagger" } +dagger-android-support = { module = "com.google.dagger:dagger-android-support", version.ref = "dagger" } +dagger-android-processor = { module = "com.google.dagger:dagger-android-processor", version.ref = "dagger" } [plugins] @@ -83,3 +83,6 @@ android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } google-gms-google-services = { id = "com.google.gms.google-services", version.ref = "googleGmsGoogleServices" } kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization" } +android-library = { id = "com.android.library", version.ref = "agp" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index e8df23b0..3f9177cf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,5 +19,15 @@ dependencyResolutionManagement { } } +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + rootProject.name = "KNUTICE" include(":app") +include(":core:network") +include(":core:data") +include(":feature:main") +include(":feature:bookmark") +include(":common") +include(":core:notification") +include(":core:domain") +include(":core:model")