diff --git a/.github/workflows/GenerateBuildArtifact.yml b/.github/workflows/GenerateBuildArtifact.yml
index 4a0a137e..0c81bd26 100644
--- a/.github/workflows/GenerateBuildArtifact.yml
+++ b/.github/workflows/GenerateBuildArtifact.yml
@@ -2,7 +2,7 @@ name: Generate Build Artifact
on:
pull_request:
- branches: ["main", "release"]
+ branches: ["release"]
jobs:
build:
@@ -37,10 +37,10 @@ jobs:
run: ./gradlew clean
- name: Build with Gradle
- run: ./gradlew
+ run: ./gradlew build
- - name: Generate and upload a Build Artifact
- uses: actions/upload-artifact@v3.1.3
- with:
- name: KNUTICE_RC.apk
- path: app/build/outputs/apk/debug/app-debug.apk
\ No newline at end of file
+# - name: Generate and upload a Build Artifact
+# uses: actions/upload-artifact@v3.1.3
+# with:
+# name: KNUTICE_RC.apk
+# path: app/build/outputs/apk/debug/app-debug.apk
\ No newline at end of file
diff --git a/.github/workflows/ValidateDevelopmentForRelease.yml b/.github/workflows/ValidateDevelopmentForRelease.yml
deleted file mode 100644
index 88984bd3..00000000
--- a/.github/workflows/ValidateDevelopmentForRelease.yml
+++ /dev/null
@@ -1,40 +0,0 @@
-name: Validate Development for Release
-
-on:
- push:
- branches: ["development"]
-
-jobs:
- build:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v4.1.1
-
- - name: Setup Java JDk
- uses: actions/setup-java@v3.0.0
- with:
- java-version: '17'
- distribution: 'adopt'
-
- - name: Change permissions of Wrapper
- run: chmod +x ./gradlew
-
- - name: Decode BASEURL
- env:
- BASEURL: ${{ secrets.BASEURL }}
- run: echo BASEURL="$BASEURL" > ./local.properties
-
- - name: Create Necessary File for Building Project
- run: cat /home/runner/work/KNUTICE-Android/KNUTICE-Android/app/google-services.json | base64
-
- - name: Putting Data into Created File.
- env:
- DATA: ${{ secrets.GOOGLE_SERVICES_JSON }}
- run: echo $DATA > /home/runner/work/KNUTICE-Android/KNUTICE-Android/app/google-services.json
-
- - name: Clean Project
- run: ./gradlew clean
-
- - name: Build with Gradle
- run: ./gradlew build
\ No newline at end of file
diff --git a/README.md b/README.md
index f04450cc..beb5fac2 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,9 @@
-
+
+[
](https://play.google.com/store/apps/details?id=com.doyoonkim.knutice)
+
+
# ๐ KNUTICE
- ๊ตญ๋ฆฝํ๊ตญ๊ตํต๋ํ๊ต ๊ณต์ง์ฌํญ ์๋ฆฌ๋ฏธ
@@ -29,14 +32,13 @@
# ๐ฑ Preview
-
-
-

-
-

-
-

-
+
@@ -44,7 +46,9 @@
# ๐ง What I learned
- Jetpack Compose๋ฅผ ์ด์ฉํ์ฌ UI ์ค๊ณ/๊ตฌํํ๋ ๋ฐฉ๋ฒ, Jetpack Compose๋ก ๊ตฌํํ UI์ ์๋ช
์ฃผ๊ธฐ ๊ด๋ฆฌ๋ฑ๊ณผ ๋๋ถ์ด ์ค์ ํด๋ผ์ด์ธํธ์ Jetpack Compose๋ฅผ ํจ์จ์ ์ผ๋ก ์ ๋ชฉ์ํค๋ ๋ฐฉ๋ฒ์ ๋ํด ์๊ฒ ๋์์ด์.
- Kotlin Flow๋ฅผ ํ์ฉํ์ฌ MVVM ์ํคํ
์ณ๋ฅผ ์ค์ํ๋ฉฐ ๊ฐ ๋ ์ด์ด๋ฅผ ์ด์ด์ฃผ๋ ๋ฐ์ดํฐ ํ์ดํ๋ผ์ธ ๊ตฌ์ถ์ ๋ ์์ธํ ์๊ฒ ๋์์ด์.
+ > snapshotFlow๋ฅผ ํตํ์ฌ ์ง์๋๋ ๋ฐ์ดํฐ ์
๋ ฅ์ ์๊ตฌ ์กฐ๊ฑด์ ๋ง๊ฒ ์ฒ๋ฆฌํ๋ ๋ฐฉ๋ฒ์ ๋ํด ์๊ฒ ๋์์ด์.
- Dagger Hilt๋ฅผ ์ฌ์ฉํ ์์กด์ฑ ์ฃผ์
์ ์ค์ ํด๋ผ์ด์ธํธ์ ์ ์ฉํด ๋ณด๋ฉด์ ์์กด์ฑ ์ฃผ์
๊ธฐ๋ฒ์ด ๊ฐ์ง ์ฅ์ ๋ค์ ๋ชธ์ ๊ฒฝํํ ์ ์์์ด์.
- Kotlin Coroutine์ ํ์ฉํ์ฌ ๊ธฐ๊ธฐ์ ๋ถ๋ด์ด ๋๋ ์์
(์ธ๋ถ ์ ์ฅ์์์ ๋ฐ์ดํฐ ํ๋ ๋ฑ)์ ํจ์จ์ ์ผ๋ก ๋น๋๊ธฐ ์ฒ๋ฆฌํ๋ ๋ฐฉ๋ฒ์ ๋ํด ์์ธํ ์๊ฒ ๋์์ด์.
- Jetpack Compose Navigaton์ ํ์ฉํ์ฌ ์ฑ๊ธ ์ํฐ๋นํฐ์์ ๋ค์ํ Composable ๊ฐ์ ์ ํ ๋ฐ ๋ฐ์ดํฐ ์ด๋์ ๊ฐ๋ฅํ๊ฒ ํ๋ ๋ฐฉ๋ฒ์ ๋ํด ์๊ฒ ๋์์ด์.
+- Jetpack Datastore๋ฅผ ์ฌ์ฉํ์ฌ ์ฌ์ฉ์ ์ค์ ๊ณผ ๊ฐ์ ์์ ๋ฐ์ดํฐ๋ฅผ ๋ณด๊ดํ๋ ๋ก์ปฌ ์คํ ๋ฆฌ์ง์ ์ฌ์ฉ๋ฒ๊ณผ ํ์ฉ์ ๋ํด ๋ ์์ธํ ์๊ฒ ๋์์ด์.
- GitHub Action๋ฅผ ์ฌ์ฉํ CI๊ธฐ๋ฅ์ ๊ตฌํํด ๋ณผ ์ ์์์ด์.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 1ef17f20..4035c501 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -11,6 +11,9 @@ plugins {
alias(libs.plugins.google.gms.google.services)
alias(libs.plugins.kotlinSerialization)
+
+ // KSP Plugin for Room Database
+// id("com.google.devtools.ksp")
}
android {
@@ -26,8 +29,8 @@ android {
applicationId = "com.doyoonkim.knutice"
minSdk = 29
targetSdk = 34
- versionCode = 5
- versionName = "1.1.0"
+ versionCode = 9
+ versionName = "1.2.0.01"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -35,6 +38,12 @@ android {
}
buildConfigField("String", "API_ROOT", "\"$apiRoot\"")
+
+ javaCompileOptions {
+ annotationProcessorOptions {
+ arguments["room.schemaLocation"] = "$projectDir/schemas"
+ }
+ }
}
buildTypes {
@@ -80,6 +89,7 @@ dependencies {
implementation(libs.androidx.material)
implementation(libs.firebase.messaging)
implementation(libs.firebase.messaging.directboot)
+ implementation(libs.androidx.junit.ktx)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test) // Library to test coroutines in JUnit
androidTestImplementation(libs.androidx.junit)
@@ -110,6 +120,15 @@ dependencies {
// 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)
+
}
// Allow references to generated code
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a88e540a..f8bc0689 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -10,6 +10,7 @@
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
+ android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
diff --git a/app/src/main/java/com/doyoonkim/knutice/MainApplication.kt b/app/src/main/java/com/doyoonkim/knutice/MainApplication.kt
index 6a93d433..abcceceb 100644
--- a/app/src/main/java/com/doyoonkim/knutice/MainApplication.kt
+++ b/app/src/main/java/com/doyoonkim/knutice/MainApplication.kt
@@ -4,9 +4,7 @@ import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
-import androidx.core.content.getSystemService
import com.doyoonkim.knutice.fcm.PushNotificationHandler
-import com.doyoonkim.knutice.R
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@@ -16,7 +14,14 @@ class MainApplication() : Application() {
override fun onCreate() {
super.onCreate()
- createNotificationChannel()
+ // Create channel group
+ (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).run {
+ createNotificationChannel(
+ getString(R.string.inapp_notification_channel_id),
+ getString(R.string.inapp_notificaiton_channel_name),
+ getString(R.string.inapp_notification_channel_description)
+ )
+ }
notificationHandler.requestCurrentToken()
}
@@ -24,13 +29,11 @@ class MainApplication() : Application() {
super.onTerminate()
}
- private fun createNotificationChannel() {
+ private fun createNotificationChannel(id: String, name: String, description: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- val name = getString(R.string.inapp_notificaiton_channel_name)
- val description = getString(R.string.inapp_notification_channel_description)
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel = NotificationChannel(
- getString(R.string.inapp_notification_channel_id),
+ id,
name,
importance
).apply {
diff --git a/app/src/main/java/com/doyoonkim/knutice/data/KnuticeRemoteSource.kt b/app/src/main/java/com/doyoonkim/knutice/data/KnuticeRemoteSource.kt
index 2c5d3c35..a921f68f 100644
--- a/app/src/main/java/com/doyoonkim/knutice/data/KnuticeRemoteSource.kt
+++ b/app/src/main/java/com/doyoonkim/knutice/data/KnuticeRemoteSource.kt
@@ -9,12 +9,13 @@ import com.doyoonkim.knutice.model.TopThreeNotices
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.ReportRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
-import org.jetbrains.annotations.TestOnly
import org.jsoup.Jsoup
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
@@ -54,6 +55,11 @@ class KnuticeRemoteSource @Inject constructor() {
}
}
+ suspend fun queryNoticesByKeyword(keyword: String): NoticesPerPage {
+ Log.d("KnuticeRemoteSource", "Start retrofit service (Querying Notices...)")
+ return knuticeService.create(KnuticeService::class.java).queryNoticeByKeyword(keyword)
+ }
+
suspend fun getFullNoticeContent(url: String): Deferred
=
CoroutineScope(Dispatchers.IO).async {
Jsoup.connect(url)
@@ -103,6 +109,28 @@ class KnuticeRemoteSource @Inject constructor() {
}
}
+ 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?.message}")
+ return Result.success(false)
+ }
+ }
+ } catch (e: Exception) {
+ Log.d("KnuticeServer", "Failed to submit user report. \nREASON: ${e.message}")
+ return Result.failure(e)
+ }
+ }
+
}
interface KnuticeService {
@@ -127,6 +155,11 @@ interface KnuticeService {
@Query("noticeName") category: NoticeCategory
): NoticesPerPage
+ @GET("/open-api/search")
+ suspend fun queryNoticeByKeyword(
+ @Query("keyword") keyword: String
+ ): NoticesPerPage
+
@Headers("Content-Type: application/json")
@POST("/open-api/token")
suspend fun validateToken(
@@ -139,4 +172,10 @@ interface KnuticeService {
@Body requestBody: ApiReportRequest
): ApiPostResult
+ @Headers("Content-Type: application/json")
+ @POST("/open-api/token/topic")
+ suspend fun submitTopicSubscriptionPreference(
+ @Body requestBody: ApiTopicSubscriptionRequest
+ ): ApiPostResult
+
}
diff --git a/app/src/main/java/com/doyoonkim/knutice/data/NoticeLocalRepository.kt b/app/src/main/java/com/doyoonkim/knutice/data/NoticeLocalRepository.kt
index a79d1436..7bd060ae 100644
--- a/app/src/main/java/com/doyoonkim/knutice/data/NoticeLocalRepository.kt
+++ b/app/src/main/java/com/doyoonkim/knutice/data/NoticeLocalRepository.kt
@@ -1,17 +1,16 @@
package com.doyoonkim.knutice.data
import androidx.annotation.WorkerThread
-import com.doyoonkim.knutice.domain.NoticeDummySource
+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 com.doyoonkim.knutice.model.TopThreeNotices
import dagger.hilt.android.scopes.ActivityRetainedScoped
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import javax.inject.Inject
@@ -22,10 +21,53 @@ ActivityRetainedComponent lives across configuration changes, so it is created a
@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 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 {
@@ -48,9 +90,29 @@ class NoticeLocalRepository @Inject constructor(
}.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
new file mode 100644
index 00000000..95ac77e0
--- /dev/null
+++ b/app/src/main/java/com/doyoonkim/knutice/data/local/KnuticeLocalSource.kt
@@ -0,0 +1,72 @@
+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
new file mode 100644
index 00000000..fd42cf56
--- /dev/null
+++ b/app/src/main/java/com/doyoonkim/knutice/data/local/LocalRoomDatabase.kt
@@ -0,0 +1,31 @@
+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/data/local/MainDatabaseDao.kt b/app/src/main/java/com/doyoonkim/knutice/data/local/MainDatabaseDao.kt
new file mode 100644
index 00000000..c8606812
--- /dev/null
+++ b/app/src/main/java/com/doyoonkim/knutice/data/local/MainDatabaseDao.kt
@@ -0,0 +1,38 @@
+package com.doyoonkim.knutice.data.local
+
+import androidx.room.Dao
+import androidx.room.Delete
+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
+
+@Dao
+interface MainDatabaseDao {
+ @Query("SELECT * FROM Bookmark")
+ fun getAllBookmarks(): List
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ fun createBookmark(entity: Bookmark)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ fun createNoticeEntity(entity: NoticeEntity)
+
+ @Update
+ fun updateBookmark(updated: Bookmark)
+
+ @Delete
+ fun deleteBookmark(target: Bookmark)
+
+ @Delete
+ fun deleteNoticeEntity(target: NoticeEntity)
+
+ @Query("SELECT * FROM NoticeEntity WHERE ntt_id=:nttId")
+ fun getNoticeByNttId(nttId: Int): NoticeEntity
+
+ @Query("SELECT * FROM Bookmark WHERE target_ntt_id=:nttId")
+ fun getBookmarkByNttId(nttId: Int): Bookmark?
+
+}
\ 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
new file mode 100644
index 00000000..fe17d422
--- /dev/null
+++ b/app/src/main/java/com/doyoonkim/knutice/domain/FetchBookmarkFromDatabase.kt
@@ -0,0 +1,20 @@
+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
index f9207933..7f9aef2d 100644
--- a/app/src/main/java/com/doyoonkim/knutice/domain/FetchListOfNotices.kt
+++ b/app/src/main/java/com/doyoonkim/knutice/domain/FetchListOfNotices.kt
@@ -2,10 +2,27 @@ 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
index 18f6b4ad..82a43812 100644
--- a/app/src/main/java/com/doyoonkim/knutice/domain/FetchNoticesPerPageInCategory.kt
+++ b/app/src/main/java/com/doyoonkim/knutice/domain/FetchNoticesPerPageInCategory.kt
@@ -3,7 +3,6 @@ package com.doyoonkim.knutice.domain
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.map
import javax.inject.Inject
@@ -18,17 +17,8 @@ class FetchNoticesPerPageInCategory @Inject constructor(
}
}
- private 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"
- )
- }
+ 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/FetchTopThreeNoticeByCategory.kt b/app/src/main/java/com/doyoonkim/knutice/domain/FetchTopThreeNoticeByCategory.kt
index c7e092c6..c55e4689 100644
--- a/app/src/main/java/com/doyoonkim/knutice/domain/FetchTopThreeNoticeByCategory.kt
+++ b/app/src/main/java/com/doyoonkim/knutice/domain/FetchTopThreeNoticeByCategory.kt
@@ -55,6 +55,7 @@ class FetchTopThreeNoticeByCategory @Inject constructor (
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",
diff --git a/app/src/main/java/com/doyoonkim/knutice/domain/QueryNoticesUsingKeyword.kt b/app/src/main/java/com/doyoonkim/knutice/domain/QueryNoticesUsingKeyword.kt
new file mode 100644
index 00000000..ec242988
--- /dev/null
+++ b/app/src/main/java/com/doyoonkim/knutice/domain/QueryNoticesUsingKeyword.kt
@@ -0,0 +1,23 @@
+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
index 95a27243..e87d4715 100644
--- a/app/src/main/java/com/doyoonkim/knutice/fcm/PushNotificationHandler.kt
+++ b/app/src/main/java/com/doyoonkim/knutice/fcm/PushNotificationHandler.kt
@@ -1,6 +1,7 @@
package com.doyoonkim.knutice.fcm
import android.Manifest
+import android.app.Notification
import android.content.pm.PackageManager
import android.graphics.drawable.Icon
import android.util.Log
@@ -46,6 +47,7 @@ class PushNotificationHandler @Inject constructor() : FirebaseMessagingService()
}
private fun RemoteMessage.toPushNotification() {
+ 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)
@@ -73,7 +75,7 @@ class PushNotificationHandler @Inject constructor() : FirebaseMessagingService()
// for ActivityCompat#requestPermissions for more details.
return
}
- notify(Random(System.currentTimeMillis().toInt()).nextInt(), notificationBuilder.build())
+ notify(notificationId, notificationBuilder.build())
}
}
diff --git a/app/src/main/java/com/doyoonkim/knutice/model/DataWrappers.kt b/app/src/main/java/com/doyoonkim/knutice/model/DataWrappers.kt
index ace3ac8b..a9efebb3 100644
--- a/app/src/main/java/com/doyoonkim/knutice/model/DataWrappers.kt
+++ b/app/src/main/java/com/doyoonkim/knutice/model/DataWrappers.kt
@@ -1,6 +1,8 @@
package com.doyoonkim.knutice.model
+import androidx.room.Entity
import com.google.gson.annotations.SerializedName
+import kotlinx.serialization.Serializable
data class Result(
@@ -67,7 +69,20 @@ data class ReportRequest(
val version: String = ""
)
+data class ApiTopicSubscriptionRequest(
+ val result: Result = Result(),
+ val body: ManageTopicRequest = ManageTopicRequest()
+)
+
+data class ManageTopicRequest(
+ val deviceToken: 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",
@@ -75,21 +90,77 @@ data class Notice(
val imageUrl: String = "",
val departName: String = "Unknown",
val timestamp: String = "Unknown"
-)
+) {
+ fun toFullContent(): FullContent {
+ return FullContent(
+ title,
+ "[$departName] $timestamp",
+ url,
+ imageUrl
+ )
+ }
+
+ fun toNoticeEntity(): NoticeEntity {
+ return NoticeEntity(
+ noticeEntityId = 0,
+ nttId = nttId,
+ title = title,
+ url = url,
+ imageUrl = imageUrl,
+ departName = departName,
+ timestamp = timestamp
+ )
+ }
+}
+// DetailedNoticeContent
data class DetailedContentState(
+ val url: String = "",
val title: String = "",
val info: String = "",
val fullContent: String = "",
val fullContentUrl: String = "",
val imageUrl: String = "",
- val isLoaded: Boolean = false
+ 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(true, true, true, true)
+)
+
+// 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
+)
+
diff --git a/app/src/main/java/com/doyoonkim/knutice/model/RoomEntities.kt b/app/src/main/java/com/doyoonkim/knutice/model/RoomEntities.kt
new file mode 100644
index 00000000..1c18f1ea
--- /dev/null
+++ b/app/src/main/java/com/doyoonkim/knutice/model/RoomEntities.kt
@@ -0,0 +1,40 @@
+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
index 14b7e9cc..08dee5c5 100644
--- a/app/src/main/java/com/doyoonkim/knutice/model/Types.kt
+++ b/app/src/main/java/com/doyoonkim/knutice/model/Types.kt
@@ -4,7 +4,8 @@ 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, SETTINGS, OSS, CS, 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
diff --git a/app/src/main/java/com/doyoonkim/knutice/navigation/MainNavigator.kt b/app/src/main/java/com/doyoonkim/knutice/navigation/MainNavigator.kt
index 3876f2a1..d25f0026 100644
--- a/app/src/main/java/com/doyoonkim/knutice/navigation/MainNavigator.kt
+++ b/app/src/main/java/com/doyoonkim/knutice/navigation/MainNavigator.kt
@@ -1,5 +1,10 @@
package com.doyoonkim.knutice.navigation
+import android.util.Log
+import androidx.activity.compose.BackHandler
+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
@@ -12,12 +17,17 @@ 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.MainActivityViewModel
@Composable
@@ -38,34 +48,84 @@ fun MainNavigator(
updatedCurrentLocation = destination.arrived
)
- when (destination.arrived) {
- Destination.MAIN -> CategorizedNotification(
- onGoBackAction = { navController.popBackStack() },
- onMoreNoticeRequested = { navController.navigate(NavDestination(arrived = it)) },
- onFullContentRequested = { navController.navigate(it) }
+ 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 = { /* Disable Swipe-to-Back on Main Page of the App */ },
+ 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),
- onCustomerServiceClicked = { navController.navigate(NavDestination(it))}
- ) {
+ Modifier.padding(top = 20.dp, start = 10.dp, end = 10.dp).fillMaxSize(),
+ onCustomerServiceClicked = { navController.navigate(NavDestination(it))},
+ onNotificationPreferenceClicked = { navController.navigate(NavDestination(it)) }) {
navController.navigate(NavDestination(it))
}
Destination.OSS -> OpenSourceLicenseNotice()
- Destination.CS -> CustomerService(Modifier.padding(15.dp))
+ Destination.CS -> { CustomerService(Modifier.padding(15.dp)) }
+ Destination.SEARCH -> SearchNotice(
+ onBackClicked = { navController.popBackStack() },
+ 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 = { /* Disable swipe-to-back on BOOKMARKS composable of the app */ }
+ )
else -> MoreCategorizedNotification(
backButtonHandler = { navController.popBackStack() },
- onNoticeSelected = { navController.navigate(it) }
+ onNoticeSelected = {
+ viewModel.updateState(updatedTempReservedNoticeForBookmark = it)
+ navController.navigate(it.toFullContent())
+ }
)
}
}
composable { backStackEntry ->
- val scaffoldTitle = backStackEntry.toRoute().title
+ val requestedNotice = backStackEntry.toRoute()
+ val scaffoldTitle = requestedNotice.title
viewModel.updateState(
- updatedCurrentLocation = Destination.Unspecified,
- updatedCurrentScaffoldTitle = scaffoldTitle ?: "Full Content"
+ updatedCurrentLocation = Destination.DETAILED,
+ updatedCurrentScaffoldTitle = scaffoldTitle ?: "Full Content",
+ updatedBottomNavBarVisibility = true
)
DetailedNoticeContent()
+ Spacer(Modifier.height(20.dp))
}
+
+ composable {
+ viewModel.updateState(
+ updatedCurrentLocation = Destination.EDIT_BOOKMARK,
+ updatedBottomNavBarVisibility = false
+ )
+ EditBookmark(Modifier.fillMaxSize().padding(10.dp)) {
+ // onSavedClicked
+ 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
new file mode 100644
index 00000000..1d348a2c
--- /dev/null
+++ b/app/src/main/java/com/doyoonkim/knutice/presentation/BookmarkComposable.kt
@@ -0,0 +1,88 @@
+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.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.displayBackground)
+ .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/CategorizedNoficiation.kt b/app/src/main/java/com/doyoonkim/knutice/presentation/CategorizedNoficiation.kt
index a8112fcf..255f8167 100644
--- a/app/src/main/java/com/doyoonkim/knutice/presentation/CategorizedNoficiation.kt
+++ b/app/src/main/java/com/doyoonkim/knutice/presentation/CategorizedNoficiation.kt
@@ -1,19 +1,21 @@
package com.doyoonkim.knutice.presentation
import android.content.res.Configuration
-import android.util.Log
import androidx.activity.compose.BackHandler
-import androidx.compose.foundation.clickable
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -44,7 +46,7 @@ fun CategorizedNotification(
viewModel: CategorizedNotificationViewModel = hiltViewModel(),
onGoBackAction: () -> Unit,
onMoreNoticeRequested: (Destination) -> Unit,
- onFullContentRequested: (FullContent) -> Unit
+ onFullContentRequested: (Notice) -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
@@ -65,8 +67,8 @@ fun CategorizedNotification(
titleColor = MaterialTheme.colorScheme.notificationType1,
contents = uiState.notificationGeneral,
onMoreClicked = { onMoreNoticeRequested(Destination.MORE_GENERAL) }
- ) { title, info, url, imgUrl ->
- onFullContentRequested(FullContent(title, info, url, imgUrl))
+ ) {
+ onFullContentRequested(it)
}
NotificationPreviewList(
@@ -74,8 +76,8 @@ fun CategorizedNotification(
titleColor = MaterialTheme.colorScheme.notificationType2,
contents = uiState.notificationAcademic,
onMoreClicked = { onMoreNoticeRequested(Destination.MORE_ACADEMIC) }
- ) { title, info, url, imgUrl ->
- onFullContentRequested(FullContent(title, info, url, imgUrl))
+ ) {
+ onFullContentRequested(it)
}
NotificationPreviewList(
@@ -83,8 +85,8 @@ fun CategorizedNotification(
titleColor = MaterialTheme.colorScheme.notificationType3,
contents = uiState.notificationScholarship,
onMoreClicked = { onMoreNoticeRequested(Destination.MORE_SCHOLARSHIP) }
- ) { title, info, url, imgUrl ->
- onFullContentRequested(FullContent(title, info, url, imgUrl))
+ ) {
+ onFullContentRequested(it)
}
NotificationPreviewList(
@@ -92,8 +94,8 @@ fun CategorizedNotification(
titleColor = MaterialTheme.colorScheme.notificationType4,
contents = uiState.notificationEvent,
onMoreClicked = { onMoreNoticeRequested(Destination.MORE_EVENT) }
- ) { title, info, url, imgUrl ->
- onFullContentRequested(FullContent(title, info, url, imgUrl))
+ ) {
+ onFullContentRequested(it)
}
}
}
@@ -105,7 +107,7 @@ fun NotificationPreviewList(
titleColor: Color = Color.Unspecified,
contents: List = listOf(),
onMoreClicked: () -> Unit = { },
- onNoticeClicked: (String, String, String, String) -> Unit
+ onNoticeClicked: (Notice) -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth()
@@ -114,9 +116,11 @@ fun NotificationPreviewList(
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
- Modifier.fillMaxWidth()
+ Modifier.fillMaxWidth(
+
+ )
.wrapContentHeight(),
- verticalAlignment = Alignment.Bottom,
+ verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
@@ -127,33 +131,36 @@ fun NotificationPreviewList(
fontWeight = FontWeight.Bold
)
- Text(
- modifier = Modifier.fillMaxWidth().weight(1f)
- .clickable { onMoreClicked() },
- text = stringResource(R.string.btn_more),
- color = MaterialTheme.colorScheme.subTitle,
- fontSize = 13.sp,
- fontWeight = FontWeight.Medium
- )
+ TextButton(
+ modifier = Modifier.fillMaxWidth().weight(1f),
+ onClick = { onMoreClicked() },
+ contentPadding = PaddingValues(0.dp)
+ ) {
+ Text(
+ modifier = Modifier.align(Alignment.CenterVertically),
+ text = stringResource(R.string.btn_more),
+ color = MaterialTheme.colorScheme.subTitle,
+ fontWeight = FontWeight.Medium
+ )
+ }
}
contents.forEach { content ->
NotificationPreviewCard(
notificationTitle = content.title,
notificationInfo = "[${content.departName}] ${content.timestamp}"
) {
- onNoticeClicked(
- content.title,
- "[${content.departName}] ${content.timestamp}",
- content.url,
- content.imageUrl
- )
+ onNoticeClicked(content)
}
}
}
}
@Composable
-@Preview(showBackground = true, showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@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/CustomerService.kt b/app/src/main/java/com/doyoonkim/knutice/presentation/CustomerService.kt
index 9f55edca..782b8728 100644
--- a/app/src/main/java/com/doyoonkim/knutice/presentation/CustomerService.kt
+++ b/app/src/main/java/com/doyoonkim/knutice/presentation/CustomerService.kt
@@ -110,7 +110,7 @@ fun CustomerService(
Button(
modifier = Modifier.fillMaxWidth().wrapContentHeight(),
- enabled = !uiState.isSubmissionCompleted && uiState.userReport.isNotBlank(),
+ enabled = !uiState.isSubmissionCompleted && uiState.exceedMinCharacters,
shape = RoundedCornerShape(10.dp),
onClick = { viewModel.submitUserReport() }
) {
diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/DetailedNoticeContent.kt b/app/src/main/java/com/doyoonkim/knutice/presentation/DetailedNoticeContent.kt
index 65da217f..278cc3ba 100644
--- a/app/src/main/java/com/doyoonkim/knutice/presentation/DetailedNoticeContent.kt
+++ b/app/src/main/java/com/doyoonkim/knutice/presentation/DetailedNoticeContent.kt
@@ -1,49 +1,29 @@
package com.doyoonkim.knutice.presentation
-import android.content.Intent
-import android.net.Uri
+import android.content.res.Configuration
+import android.util.Log
+import android.view.View
+import android.webkit.WebChromeClient
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
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.Row
import androidx.compose.foundation.layout.fillMaxSize
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.foundation.rememberScrollState
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-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.draw.clip
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.layout.ContentScale
-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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import coil.compose.AsyncImage
-import coil.request.ImageRequest
-import com.doyoonkim.knutice.ui.theme.buttonContainer
-import com.doyoonkim.knutice.ui.theme.containerBackground
-import com.doyoonkim.knutice.R
-import com.doyoonkim.knutice.presentation.component.LazyText
+import com.doyoonkim.knutice.ui.theme.displayBackground
import com.doyoonkim.knutice.viewModel.DetailedNoticeContentViewModel
@Composable
@@ -51,110 +31,106 @@ fun DetailedNoticeContent(
modifier: Modifier = Modifier,
viewModel: DetailedNoticeContentViewModel = hiltViewModel()
) {
- val localContext = LocalContext.current
-
val state by viewModel.uiState.collectAsStateWithLifecycle()
- LaunchedEffect(Unit) {
- viewModel.requestFullContent()
- }
-
Column(
- Modifier.padding(15.dp)
+ modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.displayBackground),
+ verticalArrangement = Arrangement.Top,
+ horizontalAlignment = Alignment.CenterHorizontally
) {
- Row(
- Modifier.fillMaxWidth()
- .weight(0.5f),
- horizontalArrangement = Arrangement.Center,
- verticalAlignment = Alignment.Bottom
- ) {
- LazyText(
- modifier = Modifier.weight(5f).wrapContentHeight(),
- text = state.title,
- fontSize = 28.sp,
- fontWeight = FontWeight.SemiBold,
- maxLine = 1,
- overflow = TextOverflow.Ellipsis,
- completion = state.isLoaded
- )
- }
-
- LazyText(
- modifier = Modifier
- .fillMaxWidth()
- .wrapContentHeight()
- .weight(0.5f),
- text = state.info,
- fontSize = 16.sp,
- fontWeight = FontWeight.Normal,
- completion = state.isLoaded
+ LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth().wrapContentHeight(),
+ progress = {
+ state.loadingStatue
+ }
)
+ AndroidView(
+ modifier = Modifier,
+ factory = { context ->
+ WebView(context).apply {
+ //Enable Javascript
+ // Security Alert: XSS Vulnerability
+ settings.javaScriptEnabled = true
- Surface(
- modifier = Modifier.fillMaxWidth()
- .weight(8f)
- .verticalScroll(rememberScrollState()),
- color = MaterialTheme.colorScheme.containerBackground,
- shape = RoundedCornerShape(10.dp)
- ) {
- Column(
- modifier = Modifier.fillMaxSize(),
- verticalArrangement = Arrangement.Top
- ) {
- if (state.imageUrl != "") {
- AsyncImage(
- model = ImageRequest.Builder(LocalContext.current)
- .data(state.imageUrl)
- .crossfade(true)
- .build(),
- contentDescription = "Loaded Image, which is a part of the notice.",
- contentScale = ContentScale.FillWidth,
- modifier = Modifier.fillMaxSize().padding(7.dp)
- .clip(RoundedCornerShape(10.dp))
- )
- }
- LazyText(
- modifier = Modifier.fillMaxWidth().padding(10.dp),
- text = state.fullContent,
- fontSize = 18.sp,
- fontWeight = FontWeight.Medium,
- completion = state.isLoaded
- )
- }
- }
+ webViewClient = object : WebViewClient() {
+ override fun onPageFinished(view: WebView?, url: String?) {
+ val theme = context.resources.configuration.uiMode.and(Configuration.UI_MODE_NIGHT_MASK)
+ when (theme) {
+ Configuration.UI_MODE_NIGHT_YES -> {
+ evaluateJavascript(
+ """
+ var themeStyle = 'div, p, span, ul { background-color: #262729 !important; color: #ffffff !important; } .bbs_detail { border-top: 0px; } .bbs_detail_tit, .info { background-color: #333437 !important; color: #ffffff !important; border-radius: 15px; border-bottom: 0px; } .bbs_detail_tit h2 {color: #ffffff !important } .bbs_detail_tit .info li { color: #ffffff !important } .bbs_detail span { color: #ffffff !important } .bbs_detail_file { background-color: #787879 !important; color: #ffffff !important; border-radius: 15px; margin-top: 10px; padding: 15px; } .bbs_detail_file a { color: #ffffff; }',
+ head = document.head || document.getElementsByTagName('head')[0],
+ style = document.createElement('style');
+
+ head.appendChild(style);
+ style.type = 'text/css';
+ if (style.styleSheet) {
+ style.styleSheet.cssText = themeStyle;
+ } else {
+ style.appendChild(document.createTextNode(themeStyle));
+ }
+ """.trimIndent()
+ ) {
+ Log.d("DetailedNoticeContent", "Dark Theme Applied")
+ }
+ }
+ }
- Button(
- onClick = {
- if (state.fullContentUrl != "") {
- val webIntent = Intent(Intent.ACTION_VIEW, Uri.parse(state.fullContentUrl))
- localContext.startActivity(webIntent)
- }
- },
- shape = RoundedCornerShape(15.dp),
- modifier = Modifier.fillMaxWidth()
- .padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 5.dp)
- .weight(0.7f),
- colors = ButtonColors(
- containerColor = MaterialTheme.colorScheme.buttonContainer,
- contentColor = Color.White,
- disabledContentColor = Color.Unspecified,
- disabledContainerColor = Color.Unspecified
- )
- ) {
- Text(
- modifier = Modifier.fillMaxWidth(),
- text = stringResource(R.string.btn_more_on_browser),
- textAlign = TextAlign.Center,
- fontSize = 14.sp,
- fontWeight = FontWeight.SemiBold
- )
- }
+ evaluateJavascript(
+ """
+ let div_accessibility = document.getElementById('accessibility');
+ let div_header = document.getElementById('header');
+ let div_point = document.getElementById('point');
+ let div_footer = document.getElementById('footer');
+ let div_footer_root = document.getElementById('fb-root');
+
+ let section_svisual = document.getElementById('svisual');
+ let section_location = document.getElementById('location');
+ let aside_remote = document.getElementById('remote');
+
+ let p_board_butt = document.getElementsByClassName('board_butt');
+
+ div_accessibility.remove();
+ div_header.remove();
+ div_point.remove();
+ div_footer.remove();
+ div_footer_root.remove();
+
+ section_svisual.remove();
+ section_location.remove();
+ aside_remote.remove();
+ p_board_butt[0].remove();
+
+ """.trimIndent(),
+ ) { result ->
+ Log.d("Android Web View Client", "RESULT: $result")
+ visibility = View.VISIBLE
+ }
+ super.onPageFinished(view, url)
+ }
+ }
- }
+ // For Progress Indicator
+ webChromeClient = object: WebChromeClient() {
+ override fun onProgressChanged(view: WebView?, newProgress: Int) {
+ // Update progress status
+ viewModel.updateLoadingStatus(newProgress)
+ super.onProgressChanged(view, newProgress)
+ }
+ }
+ visibility = View.INVISIBLE
+ settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
+ loadUrl(state.url)
+ }
+ }
+ )
+ }
}
+
@Preview
@Composable
fun DetailedNoticeContent_Preview() {
diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/EditBookmark.kt b/app/src/main/java/com/doyoonkim/knutice/presentation/EditBookmark.kt
new file mode 100644
index 00000000..2ec2e569
--- /dev/null
+++ b/app/src/main/java/com/doyoonkim/knutice/presentation/EditBookmark.kt
@@ -0,0 +1,227 @@
+package com.doyoonkim.knutice.presentation
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.slideInVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+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.layout.wrapContentSize
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Switch
+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.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.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.presentation.component.NotificationPreviewCard
+import com.doyoonkim.knutice.ui.theme.containerBackground
+import com.doyoonkim.knutice.ui.theme.notificationType1
+import com.doyoonkim.knutice.ui.theme.subTitle
+import com.doyoonkim.knutice.ui.theme.title
+import com.doyoonkim.knutice.viewModel.EditBookmarkViewModel
+
+@Composable
+fun EditBookmark(
+ modifier: Modifier = Modifier,
+ viewModel: EditBookmarkViewModel = hiltViewModel(),
+ onSaveClicked: () -> Unit = { }
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ val localContext = LocalContext.current
+
+ Column(
+ modifier = modifier
+ ) {
+ NotificationPreviewCard(
+ modifier = Modifier.padding(5.dp),
+ notificationTitle = uiState.targetNotice.title,
+ notificationInfo = uiState.targetNotice.departName
+ )
+
+ Spacer(Modifier.height(30.dp))
+
+ Text(
+ text = stringResource(R.string.subtitle_set_reminder),
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Normal,
+ color = MaterialTheme.colorScheme.title
+ )
+
+ Column(
+ modifier = Modifier.fillMaxWidth().wrapContentHeight()
+ .background(Color.Transparent),
+ verticalArrangement = Arrangement.SpaceEvenly,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ var notSupportedMessageShowed by remember { mutableStateOf(false) }
+
+ Row(
+ modifier = Modifier.fillMaxWidth().wrapContentHeight()
+ .background(Color.Transparent)
+ .clip(RoundedCornerShape(10.dp))
+ .border(2.dp, MaterialTheme.colorScheme.containerBackground)
+ .padding(start = 10.dp, end = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = stringResource(R.string.subtitle_get_reminder),
+ textAlign = TextAlign.Start,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.title,
+ modifier = Modifier.padding(10.dp).weight(5f)
+ )
+
+ Switch(
+ checked = false,
+ enabled = true,
+ modifier = Modifier.padding(10.dp).weight(1f),
+ onCheckedChange = { notSupportedMessageShowed = true }
+ )
+ }
+
+ AnimatedVisibility(
+ modifier = Modifier.fillMaxWidth().wrapContentHeight(),
+ visible = notSupportedMessageShowed,
+ enter = slideInVertically()
+ ) {
+ Text(
+ text = stringResource(R.string.text_not_supported),
+ textAlign = TextAlign.Start,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Normal,
+ color = MaterialTheme.colorScheme.notificationType1,
+ modifier = Modifier.padding(start = 10.dp, end = 10.dp)
+ )
+ }
+ }
+
+ Spacer(Modifier.height(15.dp))
+
+ Text(
+ text = stringResource(R.string.subtitle_notes),
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Normal,
+ color = MaterialTheme.colorScheme.title
+ )
+
+ Box(
+ modifier = Modifier.fillMaxWidth().weight(5f)
+ .padding(top = 5.dp, bottom = 25.dp)
+ ) {
+ TextField(
+ modifier = Modifier.fillMaxSize(),
+ value = uiState.bookmarkNote,
+ placeholder = { Text(text = stringResource(R.string.placeholder_notes)) },
+ enabled = true,
+ onValueChange = {
+ viewModel.updateBookmarkNotes(it)
+ },
+ colors = TextFieldDefaults.colors(
+ focusedTextColor = MaterialTheme.colorScheme.title,
+ unfocusedTextColor = MaterialTheme.colorScheme.subTitle,
+ focusedContainerColor = MaterialTheme.colorScheme.containerBackground,
+ unfocusedContainerColor = MaterialTheme.colorScheme.containerBackground,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent
+ ),
+ shape = RoundedCornerShape(15.dp)
+ )
+
+ Text(
+ text = "${uiState.bookmarkNote.length}/500",
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.subTitle,
+ modifier = Modifier.wrapContentSize()
+ .padding(15.dp)
+ .align(Alignment.BottomEnd)
+ )
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth().wrapContentHeight(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(5.dp)
+ ) {
+ Button(
+ modifier = Modifier.wrapContentHeight().weight(1f),
+ enabled = true,
+ shape = RoundedCornerShape(10.dp),
+ onClick = {
+ if (!uiState.requireCreation) {
+ viewModel.modifyBookmark()
+ } else {
+ viewModel.createNewBookmark()
+ }
+ onSaveClicked()
+ }
+ ) {
+ Text(
+ text = stringResource(R.string.btn_save),
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(10.dp)
+ )
+ }
+
+ if (!uiState.requireCreation) {
+ OutlinedButton(
+ modifier = Modifier.wrapContentHeight().weight(1f),
+ enabled = true,
+ shape = RoundedCornerShape(10.dp),
+ onClick = {
+ viewModel.removeBookmark()
+ onSaveClicked()
+ }
+ ) {
+ Text(
+ text = stringResource(R.string.btn_delete),
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(10.dp)
+ )
+ }
+ }
+ }
+
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun EditBookmark_Preview() {
+ EditBookmark(Modifier.fillMaxSize().padding(10.dp)) { }
+}
+
diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/MainActivity.kt b/app/src/main/java/com/doyoonkim/knutice/presentation/MainActivity.kt
index c50217b9..99385025 100644
--- a/app/src/main/java/com/doyoonkim/knutice/presentation/MainActivity.kt
+++ b/app/src/main/java/com/doyoonkim/knutice/presentation/MainActivity.kt
@@ -8,17 +8,31 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
+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.padding
+import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.material.BottomNavigationItem
+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.material.Icon // For Using BottomNavigationItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
+import androidx.compose.material.Text // For Using BottomNavigationItem
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
@@ -26,6 +40,7 @@ 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
@@ -33,19 +48,22 @@ 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 androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.rememberNavController
+import com.doyoonkim.knutice.R
import com.doyoonkim.knutice.model.Destination
+import com.doyoonkim.knutice.model.NavDestination
import com.doyoonkim.knutice.navigation.MainNavigator
import com.doyoonkim.knutice.ui.theme.KNUTICETheme
import com.doyoonkim.knutice.ui.theme.containerBackground
+import com.doyoonkim.knutice.ui.theme.displayBackground
import com.doyoonkim.knutice.ui.theme.notificationType1
+import com.doyoonkim.knutice.ui.theme.subTitle
import com.doyoonkim.knutice.ui.theme.title
import com.doyoonkim.knutice.viewModel.MainActivityViewModel
-import com.doyoonkim.knutice.R
-import com.doyoonkim.knutice.model.NavDestination
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
@@ -83,7 +101,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
-
+// applicationContext.deleteDatabase("Main Local Database")
enableEdgeToEdge()
setContent {
KNUTICETheme {
@@ -111,7 +129,9 @@ fun MainServiceScreen(
val navController = rememberNavController()
Scaffold(
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.displayBackground),
topBar = {
TopAppBar(
title = {
@@ -119,9 +139,9 @@ fun MainServiceScreen(
modifier = Modifier.wrapContentSize(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
-
) {
- if (mainAppState.currentLocation != Destination.MAIN) {
+ if (mainAppState.currentLocation != Destination.MAIN
+ && mainAppState.currentLocation != Destination.BOOKMARKS) {
IconButton(
onClick = {
navController.popBackStack()
@@ -149,17 +169,24 @@ fun MainServiceScreen(
Destination.SETTINGS -> stringResource(R.string.title_preference)
Destination.OSS -> stringResource(R.string.oss_notice)
Destination.CS -> stringResource(R.string.title_customer_service)
- Destination.Unspecified -> mainAppState.currentScaffoldTitle
+ 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) {
- TextAlign.Center
+ 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
+ overflow = TextOverflow.Ellipsis,
+ color = MaterialTheme.colorScheme.title
)
}
},
@@ -169,6 +196,18 @@ fun MainServiceScreen(
),
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))
@@ -184,9 +223,92 @@ fun MainServiceScreen(
}
}
)
- }
+ },
+ floatingActionButton = {
+ if (mainAppState.currentLocation == Destination.DETAILED) {
+ FloatingActionButton(
+ onClick = {
+ if (mainAppState.tempReserveNoticeForBookmark.title.isNotBlank()) {
+ navController.navigate(mainAppState.tempReserveNoticeForBookmark)
+ }
+ }
+ ) {
+ Icon(Icons.Filled.Add, "Floating Action Button")
+ }
+ }
+ },
+ bottomBar = {
+ AnimatedVisibility(
+ visible = mainAppState.isBottomNavBarVisible,
+ 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("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("Bookmarks")
+ },
+ selectedContentColor = MaterialTheme.colorScheme.title,
+ unselectedContentColor = MaterialTheme.colorScheme.subTitle
+ )
+ },
+ containerColor = MaterialTheme.colorScheme.containerBackground,
+ contentColor = MaterialTheme.colorScheme.title
+ )
+ }
+ if (mainAppState.currentLocation != Destination.EDIT_BOOKMARK) {
+
+ }
+ },
+ containerColor = Color.Transparent
) { innerPadding ->
- MainNavigator(navController = navController, modifier = Modifier.padding(innerPadding))
+ val adjustmentFactor = 10.dp
+ MainNavigator(navController = navController, modifier = Modifier
+ .consumeWindowInsets(WindowInsets.systemBars)
+ .padding(
+ PaddingValues(
+ top = innerPadding.calculateTopPadding(),
+ bottom = innerPadding.calculateBottomPadding()
+ )
+ )
+ .background(MaterialTheme.colorScheme.displayBackground)
+ )
}
}
@Composable
diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/MoreCategorizedNoticiation.kt b/app/src/main/java/com/doyoonkim/knutice/presentation/MoreCategorizedNoticiation.kt
index c346f3e0..768de7d2 100644
--- a/app/src/main/java/com/doyoonkim/knutice/presentation/MoreCategorizedNoticiation.kt
+++ b/app/src/main/java/com/doyoonkim/knutice/presentation/MoreCategorizedNoticiation.kt
@@ -27,7 +27,7 @@ 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.FullContent
+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
@@ -39,7 +39,7 @@ fun MoreCategorizedNotification(
modifier: Modifier = Modifier,
viewModel: MoreCategorizedNotificationViewModel = hiltViewModel(),
backButtonHandler: () -> Unit = { },
- onNoticeSelected: (FullContent) -> Unit = { }
+ onNoticeSelected: (Notice) -> Unit = { }
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -94,12 +94,7 @@ fun MoreCategorizedNotification(
Row(
modifier = Modifier.wrapContentSize()
.clickable {
- onNoticeSelected(FullContent(
- notice.title,
- "[${notice.departName}] ${notice.timestamp}",
- notice.url,
- notice.imageUrl
- ))
+ onNoticeSelected(notice)
}
) {
NotificationPreview(
diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/NotificationPerferences.kt b/app/src/main/java/com/doyoonkim/knutice/presentation/NotificationPerferences.kt
new file mode 100644
index 00000000..254c162a
--- /dev/null
+++ b/app/src/main/java/com/doyoonkim/knutice/presentation/NotificationPerferences.kt
@@ -0,0 +1,233 @@
+package com.doyoonkim.knutice.presentation
+
+import android.content.Intent
+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.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.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()
+ }
+
+ LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
+ viewModel.checkMainNotificationPreferenceStatus()
+ }
+
+ Column(
+ modifier = Modifier.fillMaxSize().systemBarsPadding(),
+ 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
index c64da1af..21c27196 100644
--- a/app/src/main/java/com/doyoonkim/knutice/presentation/OssNotice.kt
+++ b/app/src/main/java/com/doyoonkim/knutice/presentation/OssNotice.kt
@@ -14,7 +14,7 @@ fun OpenSourceLicenseNotice(
modifier: Modifier = Modifier
) {
AndroidView(
- modifier = modifier.fillMaxWidth().padding(5.dp),
+ modifier = modifier.padding(),
factory = { context ->
WebView(context).apply {
loadUrl("https://knutice.github.io/KNUTICE-OpenSourceLicense/Android/opensource.html")
diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/SearchNoticeComposable.kt b/app/src/main/java/com/doyoonkim/knutice/presentation/SearchNoticeComposable.kt
new file mode 100644
index 00000000..ab1c114b
--- /dev/null
+++ b/app/src/main/java/com/doyoonkim/knutice/presentation/SearchNoticeComposable.kt
@@ -0,0 +1,165 @@
+package com.doyoonkim.knutice.presentation
+
+import android.content.res.Configuration
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+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.PaddingValues
+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.wrapContentHeight
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+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
+
+@OptIn(FlowPreview::class)
+@Composable
+fun SearchNotice(
+ modifier: Modifier = Modifier,
+ viewModel: SearchNoticeViewModel = hiltViewModel(),
+ onBackClicked: () -> Unit = { },
+ onNoticeClicked: (Notice) -> Unit
+) {
+
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ snapshotFlow{ uiState.searchKeyword }
+ .debounce(500L)
+ .distinctUntilChanged()
+ .filter { it.isNotBlank() }
+ .collectLatest {
+ viewModel.queryNoticeByKeyword(it)
+ }
+ }
+
+ BackHandler { onBackClicked() }
+
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight(),
+ horizontalArrangement = Arrangement.spacedBy(3.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ TextField(
+ modifier = Modifier
+ .weight(8f)
+ .wrapContentHeight()
+ .padding(2.dp),
+ value = uiState.searchKeyword,
+ placeholder = { Text(stringResource(R.string.title_search)) },
+ onValueChange = { viewModel.updateKeyword(it) },
+ colors = TextFieldDefaults.colors(
+ focusedTextColor = MaterialTheme.colorScheme.title,
+ unfocusedTextColor = MaterialTheme.colorScheme.subTitle,
+ focusedContainerColor = MaterialTheme.colorScheme.containerBackground,
+ unfocusedContainerColor = MaterialTheme.colorScheme.containerBackground,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent
+ ),
+ shape = RoundedCornerShape(15.dp)
+ )
+ }
+
+ Box(
+ modifier = Modifier.fillMaxSize()
+ .align(Alignment.CenterHorizontally)
+ ) {
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth()
+ .wrapContentHeight(),
+ contentPadding = PaddingValues(3.dp)
+ ) {
+ items(uiState.queryResult) { notice ->
+ HorizontalDivider(
+ Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp),
+ color = MaterialTheme.colorScheme.containerBackground
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth().wrapContentHeight()
+ .clickable {
+ onNoticeClicked(notice)
+ }
+ ) {
+ NotificationPreview(
+ modifier = Modifier.fillMaxWidth(),
+ isImageContained = notice.imageUrl != "Unknown",
+ notificationTitle = notice.title,
+ notificationInfo = "[${notice.departName}] ${notice.timestamp}",
+ imageUrl = notice.imageUrl
+ )
+ }
+
+ }
+ }
+
+ // Loading Indicator w/ AnimatedVisibility
+ androidx.compose.animation.AnimatedVisibility(
+ visible = uiState.isQuerying,
+ modifier = Modifier.wrapContentSize().align(Alignment.Center),
+ enter = scaleIn(),
+ exit = scaleOut(),
+ ) {
+ CircularProgressIndicator(
+ color = MaterialTheme.colorScheme.textPurple
+ )
+ }
+ }
+ }
+}
+
+@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/UserPreference.kt b/app/src/main/java/com/doyoonkim/knutice/presentation/UserPreference.kt
index 6ca772a2..4467c9cd 100644
--- a/app/src/main/java/com/doyoonkim/knutice/presentation/UserPreference.kt
+++ b/app/src/main/java/com/doyoonkim/knutice/presentation/UserPreference.kt
@@ -1,9 +1,7 @@
package com.doyoonkim.knutice.presentation
-import android.Manifest
-import android.content.Intent
-import android.content.pm.PackageManager
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
@@ -14,17 +12,11 @@ import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.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
@@ -32,30 +24,21 @@ 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.core.content.ContextCompat
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.compose.LifecycleEventEffect
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.
@Composable
fun UserPreference(
modifier: Modifier = Modifier,
+ onNotificationPreferenceClicked: (Destination) -> Unit,
onCustomerServiceClicked: (Destination) -> Unit,
onOssClicked: (Destination) -> Unit
) {
- var permissionStatus by remember { mutableStateOf(false) }
- val context = LocalContext.current
-
- LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
- permissionStatus = ContextCompat.checkSelfPermission(
- context, Manifest.permission.POST_NOTIFICATIONS
- ) == PackageManager.PERMISSION_GRANTED
- }
Column(
modifier = modifier,
@@ -67,7 +50,8 @@ fun UserPreference(
text = stringResource(R.string.pref_notification_title),
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
- textAlign = TextAlign.Start
+ textAlign = TextAlign.Start,
+ color = MaterialTheme.colorScheme.title
)
HorizontalDivider(
@@ -77,46 +61,23 @@ fun UserPreference(
Column(
modifier = Modifier.fillMaxWidth().wrapContentSize()
- .padding(top = 15.dp, bottom = 15.dp),
+ .padding(top = 15.dp, bottom = 15.dp)
+ .clickable {
+ onNotificationPreferenceClicked(Destination.NOTIFICATION)
+ },
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),
- fontWeight = FontWeight.Medium,
- fontSize = 18.sp,
- textAlign = TextAlign.Start
- )
-
- Text(
- modifier = Modifier.fillMaxWidth().wrapContentHeight(),
- text = stringResource(R.string.enable_service_notification_sub),
- fontWeight = FontWeight.Normal,
- fontSize = 12.sp,
- textAlign = TextAlign.Start
- )
- }
-
- Switch(
- checked = permissionStatus,
- 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
+ Text(
+ modifier = Modifier.fillMaxWidth().wrapContentHeight(),
+ text = stringResource(R.string.enable_notification_title),
+ fontWeight = FontWeight.Medium,
+ fontSize = 18.sp,
+ textAlign = TextAlign.Start,
+ color = MaterialTheme.colorScheme.title
)
}
}
@@ -132,7 +93,8 @@ fun UserPreference(
text = stringResource(R.string.title_support),
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
- textAlign = TextAlign.Start
+ textAlign = TextAlign.Start,
+ color = MaterialTheme.colorScheme.title
)
HorizontalDivider(
@@ -150,7 +112,8 @@ fun UserPreference(
text = stringResource(R.string.title_customer_service),
fontWeight = FontWeight.Medium,
fontSize = 18.sp,
- textAlign = TextAlign.Start
+ textAlign = TextAlign.Start,
+ color = MaterialTheme.colorScheme.title
)
IconButton(
@@ -175,7 +138,8 @@ fun UserPreference(
text = stringResource(R.string.about_title),
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
- textAlign = TextAlign.Start
+ textAlign = TextAlign.Start,
+ color = MaterialTheme.colorScheme.title
)
HorizontalDivider(
@@ -193,7 +157,8 @@ fun UserPreference(
text = stringResource(R.string.about_version),
fontWeight = FontWeight.Medium,
fontSize = 18.sp,
- textAlign = TextAlign.Start
+ textAlign = TextAlign.Start,
+ color = MaterialTheme.colorScheme.title
)
Text(
@@ -201,7 +166,8 @@ fun UserPreference(
text = stringResource(R.string.version_code),
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
- textAlign = TextAlign.End
+ textAlign = TextAlign.End,
+ color = MaterialTheme.colorScheme.title
)
}
@@ -220,7 +186,8 @@ fun UserPreference(
text = stringResource(R.string.about_oss),
fontWeight = FontWeight.Medium,
fontSize = 18.sp,
- textAlign = TextAlign.Start
+ textAlign = TextAlign.Start,
+ color = MaterialTheme.colorScheme.title
)
IconButton(
diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/component/NotificationPreviewCard.kt b/app/src/main/java/com/doyoonkim/knutice/presentation/component/NotificationPreviewCard.kt
index 2e51ff4c..4e9dbb66 100644
--- a/app/src/main/java/com/doyoonkim/knutice/presentation/component/NotificationPreviewCard.kt
+++ b/app/src/main/java/com/doyoonkim/knutice/presentation/component/NotificationPreviewCard.kt
@@ -17,12 +17,13 @@ import com.doyoonkim.knutice.ui.theme.containerBackground
@Composable
fun NotificationPreviewCard(
+ modifier: Modifier = Modifier,
notificationTitle: String = "Title goes here.",
notificationInfo: String = "Notification info goes here.",
onClick: () -> Unit = { /* Action should be defined. */ }
) {
Card(
- Modifier.fillMaxWidth()
+ modifier.fillMaxWidth()
.wrapContentHeight()
.clickable {
onClick()
diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/component/NotificationPreviewCardMakred.kt b/app/src/main/java/com/doyoonkim/knutice/presentation/component/NotificationPreviewCardMakred.kt
new file mode 100644
index 00000000..2017e6a0
--- /dev/null
+++ b/app/src/main/java/com/doyoonkim/knutice/presentation/component/NotificationPreviewCardMakred.kt
@@ -0,0 +1,51 @@
+package com.doyoonkim.knutice.presentation.component
+
+import android.content.res.Configuration
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentSize
+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.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
+
+@Composable
+fun NotificationPreviewCardMarked(
+ modifier: Modifier = Modifier,
+ noticeTitle: String = "Title goes here",
+ noticeSubtitle: String = "Subtitle goes here",
+ onItemClicked: () -> Unit = { },
+ onBackPressed: () -> Unit = { }
+) {
+ Box(
+ modifier = modifier.wrapContentSize(),
+ contentAlignment = Alignment.TopEnd
+ ) {
+ NotificationPreviewCard(
+ notificationTitle = noticeTitle,
+ notificationInfo = noticeSubtitle,
+ ) {
+ onItemClicked()
+ }
+ Image(
+ painter = painterResource(R.drawable.baseline_bookmarks_24),
+ contentDescription = "Bookmark Image",
+ colorFilter = ColorFilter.tint(Color.Red),
+ modifier = Modifier.padding(end = 10.dp)
+ )
+ }
+}
+
+@Preview(showSystemUi = false, showBackground = true,
+ uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL
+)
+@Composable
+fun NotificationPreviewCardMarked_Preview() {
+ NotificationPreviewCardMarked()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/doyoonkim/knutice/presentation/component/PopUpDialog.kt b/app/src/main/java/com/doyoonkim/knutice/presentation/component/PopUpDialog.kt
new file mode 100644
index 00000000..81bc93cd
--- /dev/null
+++ b/app/src/main/java/com/doyoonkim/knutice/presentation/component/PopUpDialog.kt
@@ -0,0 +1,82 @@
+package com.doyoonkim.knutice.presentation.component
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+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.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun PopUpDialog(
+ modifier: Modifier = Modifier,
+ isVisible: Boolean = false,
+ onCloseClicked: () -> Unit = { },
+ dialogContent: @Composable () -> Unit
+) {
+ AnimatedVisibility(
+ modifier = Modifier.wrapContentSize().background(Color.Transparent),
+ visible = isVisible,
+ enter = slideInVertically(initialOffsetY = { it + it / 2 }),
+ exit = slideOutVertically(targetOffsetY = { it / 2 })
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize()
+ .clickable(false) { /* DO NOTHING HERE */ }
+ .background(Color.Transparent)
+ .padding(10.dp)
+ ) {
+ Column(
+ modifier = modifier.fillMaxWidth()
+ .wrapContentHeight()
+ .align(Alignment.BottomCenter),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth().wrapContentHeight(),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ modifier = Modifier.clickable { onCloseClicked() },
+ text = "Close"
+ )
+ }
+
+ Surface(
+ modifier = Modifier.fillMaxWidth().wrapContentHeight()
+ .background(Color.Transparent)
+ .clip(RoundedCornerShape(10.dp))
+ ) {
+ dialogContent()
+ }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun PopUpDialog_Preview() {
+
+}
+
diff --git a/app/src/main/java/com/doyoonkim/knutice/viewModel/BookmarkViewModel.kt b/app/src/main/java/com/doyoonkim/knutice/viewModel/BookmarkViewModel.kt
new file mode 100644
index 00000000..26ec5919
--- /dev/null
+++ b/app/src/main/java/com/doyoonkim/knutice/viewModel/BookmarkViewModel.kt
@@ -0,0 +1,68 @@
+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/CustomerServiceViewModel.kt b/app/src/main/java/com/doyoonkim/knutice/viewModel/CustomerServiceViewModel.kt
index fc214300..663acc97 100644
--- a/app/src/main/java/com/doyoonkim/knutice/viewModel/CustomerServiceViewModel.kt
+++ b/app/src/main/java/com/doyoonkim/knutice/viewModel/CustomerServiceViewModel.kt
@@ -35,6 +35,7 @@ class CustomerServiceViewModel @Inject constructor(
_uiState.update {
it.copy(
userReport = content,
+ exceedMinCharacters = content.length >= 5,
reachedMaxCharacters = content.length >= 500
)
}
diff --git a/app/src/main/java/com/doyoonkim/knutice/viewModel/DetailedNoticeContentViewModel.kt b/app/src/main/java/com/doyoonkim/knutice/viewModel/DetailedNoticeContentViewModel.kt
index b33bb85f..446a65b7 100644
--- a/app/src/main/java/com/doyoonkim/knutice/viewModel/DetailedNoticeContentViewModel.kt
+++ b/app/src/main/java/com/doyoonkim/knutice/viewModel/DetailedNoticeContentViewModel.kt
@@ -3,6 +3,7 @@ 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.domain.CrawlFullContentImpl
import com.doyoonkim.knutice.model.DetailedContentState
@@ -30,6 +31,25 @@ class DetailedNoticeContentViewModel @Inject constructor(
private val requested = savedStateHandle.toRoute()
+ init {
+ _uiState.update {
+ it.copy(
+ url = requested.url
+ )
+ }
+ }
+
+ fun updateLoadingStatus(newStatus: Int) {
+ Log.d("DetailedNoticeContentViewModel", "Update loading status")
+ viewModelScope.launch {
+ _uiState.update {
+ it.copy(
+ loadingStatue = (newStatus / 100).toFloat()
+ )
+ }
+ }
+ }
+
fun requestFullContent() {
CoroutineScope(Dispatchers.IO).launch {
crawlFullContentUseCase.getFullContentFromSource(
@@ -46,8 +66,7 @@ class DetailedNoticeContentViewModel @Inject constructor(
info = content.info,
fullContent = content.fullContent,
fullContentUrl = content.fullContentUrl,
- imageUrl = requested.imgUrl,
- isLoaded = true
+ imageUrl = requested.imgUrl
)
}
},
diff --git a/app/src/main/java/com/doyoonkim/knutice/viewModel/EditBookmarkViewModel.kt b/app/src/main/java/com/doyoonkim/knutice/viewModel/EditBookmarkViewModel.kt
new file mode 100644
index 00000000..701ecb6a
--- /dev/null
+++ b/app/src/main/java/com/doyoonkim/knutice/viewModel/EditBookmarkViewModel.kt
@@ -0,0 +1,132 @@
+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 updateBookmarkNotes(newString: String) {
+ viewModelScope.launch(Dispatchers.Default) {
+ if (uiState.value.bookmarkNote.length < 500) {
+ _uiState.update {
+ it.copy(
+ bookmarkNote = newString
+ )
+ }
+ }
+ }
+ }
+
+ 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")
+ },
+ 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
+ )
+
+ localRepository.updateBookmark(modifiedBookmark)
+ }
+ }
+
+ 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/MainActivityViewModel.kt b/app/src/main/java/com/doyoonkim/knutice/viewModel/MainActivityViewModel.kt
index 85f12628..567dce6f 100644
--- a/app/src/main/java/com/doyoonkim/knutice/viewModel/MainActivityViewModel.kt
+++ b/app/src/main/java/com/doyoonkim/knutice/viewModel/MainActivityViewModel.kt
@@ -2,6 +2,7 @@ package com.doyoonkim.knutice.viewModel
import androidx.lifecycle.ViewModel
import com.doyoonkim.knutice.model.Destination
+import com.doyoonkim.knutice.model.Notice
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -15,12 +16,16 @@ class MainActivityViewModel @Inject constructor() : ViewModel() {
fun updateState(
updatedCurrentLocation: Destination = _uiState.value.currentLocation,
- updatedCurrentScaffoldTitle: String = _uiState.value.currentScaffoldTitle
+ updatedCurrentScaffoldTitle: String = _uiState.value.currentScaffoldTitle,
+ updatedBottomNavBarVisibility: Boolean = _uiState.value.isBottomNavBarVisible,
+ updatedTempReservedNoticeForBookmark: Notice = _uiState.value.tempReserveNoticeForBookmark
) {
_uiState.update {
it.copy(
currentLocation = updatedCurrentLocation,
- currentScaffoldTitle = updatedCurrentScaffoldTitle
+ currentScaffoldTitle = updatedCurrentScaffoldTitle,
+ isBottomNavBarVisible = updatedBottomNavBarVisibility,
+ tempReserveNoticeForBookmark = updatedTempReservedNoticeForBookmark
)
}
}
@@ -28,5 +33,7 @@ class MainActivityViewModel @Inject constructor() : ViewModel() {
data class MainAppState(
val currentLocation: Destination = Destination.MAIN,
- val currentScaffoldTitle: String = ""
+ val currentScaffoldTitle: String = "",
+ val isBottomNavBarVisible: Boolean = false,
+ val tempReserveNoticeForBookmark: Notice = Notice()
)
\ 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
new file mode 100644
index 00000000..db20eee3
--- /dev/null
+++ b/app/src/main/java/com/doyoonkim/knutice/viewModel/NotificationPreferenceViewModel.kt
@@ -0,0 +1,147 @@
+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.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.preferencesDataStore
+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.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+val Context.dataStore: DataStore by preferencesDataStore(
+ name = "notificationPreferences"
+)
+
+@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
+ )
+
+ init {
+ viewModelScope.launch {
+ // Current Notification Status
+ context.dataStore.data
+ .map { Result.success(it) }
+ .catch { emit(Result.failure(it)) }
+ .collect { result ->
+ result.fold(
+ onSuccess = {
+ _uiStatus.update { status ->
+ // TODO: Consider replace data type with MAP
+ status.copy(
+ isEachChannelAllowed = listOf(
+ it[booleanPreferencesKey(NoticeCategory.GENERAL_NEWS.name)] ?: true,
+ it[booleanPreferencesKey(NoticeCategory.ACADEMIC_NEWS.name)] ?: true,
+ it[booleanPreferencesKey(NoticeCategory.SCHOLARSHIP_NEWS.name)] ?: true,
+ it[booleanPreferencesKey(NoticeCategory.EVENT_NEWS.name)] ?: true
+ )
+ )
+ }
+ },
+ onFailure = {
+ Log.d("DataStore", "Unable to fetch Boolean Preferences")
+ }
+ )
+ }
+ }
+ }
+
+
+ // 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 updateChannelPreference(id: NoticeCategory, status: Boolean) {
+ viewModelScope.launch {
+ val channelStatus = List(4) {
+ if (it == notificationChannels[id]) status
+ else _uiStatus.value.isEachChannelAllowed[it]
+ }
+
+ // Local Status (ViewModel)
+ launch {
+ _uiStatus.update {
+ it.copy(
+ isEachChannelAllowed = channelStatus
+ )
+ }
+ }
+
+ // Local Data Store
+ launch {
+ context.dataStore.edit {
+ it[booleanPreferencesKey(id.name)] = status
+ }
+ }
+
+ // Synchronized with the Preference data on the server.
+ withContext(Dispatchers.IO) {
+ remoteSource.submitTopicSubscriptionPreference(id, status)
+ .fold(
+ onSuccess = {
+ Log.d("NotificationPreferenceViewModel", "Request Successful, Result: $it")
+ },
+ onFailure = {
+ Log.d("NotificationPreferenceViewModel", "Request Failure, REASON: ${it.message}")
+ }
+ )
+ }
+
+ }
+ }
+}
\ 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
new file mode 100644
index 00000000..65b43ac1
--- /dev/null
+++ b/app/src/main/java/com/doyoonkim/knutice/viewModel/SearchNoticeViewModel.kt
@@ -0,0 +1,63 @@
+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/app/src/main/res/drawable/baseline_add_circle_24.xml b/app/src/main/res/drawable/baseline_add_circle_24.xml
new file mode 100644
index 00000000..d448b37b
--- /dev/null
+++ b/app/src/main/res/drawable/baseline_add_circle_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/baseline_bookmarks_24.xml b/app/src/main/res/drawable/baseline_bookmarks_24.xml
new file mode 100644
index 00000000..a92e6a25
--- /dev/null
+++ b/app/src/main/res/drawable/baseline_bookmarks_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/baseline_home_24.xml b/app/src/main/res/drawable/baseline_home_24.xml
new file mode 100644
index 00000000..20cb4d6c
--- /dev/null
+++ b/app/src/main/res/drawable/baseline_home_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/baseline_search_24.xml b/app/src/main/res/drawable/baseline_search_24.xml
new file mode 100644
index 00000000..d29c6ea6
--- /dev/null
+++ b/app/src/main/res/drawable/baseline_search_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml
index ccd4605d..74a6fc3f 100644
--- a/app/src/main/res/values-ko-rKR/strings.xml
+++ b/app/src/main/res/values-ko-rKR/strings.xml
@@ -12,7 +12,6 @@
๋๋ณด๊ธฐ
๋ฒ์ ์ ๋ณด
์คํ์์ค ๋ผ์ด์ผ์ค
- 1.1.0
์๋ฆผ
์ค์
์๋ก์ด ์๋ฆผ์ด ๋์ฐฉํ์ด์!
@@ -29,6 +28,28 @@
์ ์ถํ๊ธฐ
์ด๊ณณ์ ๋ฌธ์์ฌํญ์ ์ ์ด์ฃผ์ธ์.
์ ์ถ์ด ์๋ฃ๋์์ด์
- ์์คํ ์๊ฒฌ๋ฅผ ์ฑ๊ณต์ ์ผ๋ก ์ ์ถํ์ด์.
+ ์์คํ ์๊ฒฌ์ ์ฑ๊ณต์ ์ผ๋ก ์ ์ถํ์ด์.
ํ์ธ
+ ๊ฒ์
+ ์ ๊ท๊ณต์ง ์๋ฆผ
+ ์ผ๋ฐ ๊ณต์ง ์๋ฆผ
+ ์ฅํ ๊ณต์ง ์๋ฆผ
+ ํ์ฌ ๊ณต์ง ์๋ฆผ
+ ํ์ฌ ๊ณต์ง ์๋ฆผ
+ ์๋ก์ด ์ผ๋ฐ ๊ณต์ง๊ฐ ์ฌ๋ผ์ค๋ฉด ์๋ฆผ์ ๋ฐ์๋์.
+ ์๋ก์ด ์ฅํ ๊ณต์ง๊ฐ ์ฌ๋ผ์ค๋ฉด ์๋ฆผ์ ๋ฐ์๋์.
+ ์๋ก์ด ํ์ฌ ๊ณต์ง๊ฐ ์ฌ๋ผ์ค๋ฉด ์๋ฆผ์ ๋ฐ์๋์.
+ ์๋ก์ด ํ์ฌ ๊ณต์ง๊ฐ ์ฌ๋ผ์ค๋ฉด ์๋ฆผ์ ๋ฐ์๋์.
+ ์๋ฆผ ์ค์
+ ์๋ฆผ ์ค์ ํ๊ธฐ
+ ๋ถ๋งํฌ ์์
+ ์๋ฆผ์ ๋ฐ์๋์
+ ๋ฉ๋ชจ
+ ๋ถ๋งํฌ ํ ๊ณต์ง์ ๋ฉ๋ชจ๋ฅผ ๋จ๊ฒจ์ฃผ์ธ์.
+ ์ ์ฅํ๊ธฐ
+ ์ ์ฅ๋ ๋ถ๋งํฌ๊ฐ ์์ด์
+ ์ญ์
+ ํ
+ ๋ถ๋งํฌ
+ ์์ง ์ค๋น์ค์ด์์. ๋ค์ ์
๋ฐ์ดํธ์์ ๋ง๋์.
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e8a0ac43..2b4c623c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -11,7 +11,7 @@
About
Version
Open Source License
- 1.1.0
+ 1.3.0
Notification
Preference
New Notice has been delivered!
@@ -34,4 +34,32 @@
Submission Completed!
Your report has been submitted!
Confirm
+ Search
+ main_group
+ KNUTICE Notice
+ notification_general
+ notification_scholar
+ notification_academic
+ notification_event
+ General Notices
+ Scholarship Notices
+ Academic Notices
+ Event Notices
+ Get push notification when new general notice is being posted.
+ Get push notification when new scholarship notice is being posted.
+ Get push notification when new academic notice is being posted.
+ Get push notification when new event notice is being posted.
+ Notification Preference
+ Set Reminder
+ Edit Bookmark
+ Get Reminder
+ Notes
+ Enter any notes for your bookmarked notice.
+ Save
+ No bookmarks to be shown
+ delete
+ Home
+ Bookmarks
+ Very Long Text for testing. Very Long Text for testing. Very Long Text for testing. Very Long Text for testing. Very Long Text for testing. Very Long Text for testing. Very Long Text for testing. Very Long Text for testing. Very Long Text for testing. Very Long Text for testing.
+ Not yet supported.
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index d996116a..404d250a 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -8,4 +8,7 @@ plugins {
alias(libs.plugins.google.gms.google.services) apply false
alias(libs.plugins.kotlinSerialization) apply false
+
+ // KSP Plugin for Room Database
+// id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false
}
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 836ae6cf..2c56ed78 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -3,6 +3,7 @@ agp = "8.6.0"
coilCompose = "2.4.0"
converterGson = "2.3.0"
converterMoshi = "2.9.0"
+datastorePreferences = "1.1.1"
firebaseMessagingDirectboot = "24.0.2"
gson = "2.11.0"
hiltAndroidCompiler = "2.51.1"
@@ -21,17 +22,25 @@ composeBom = "2024.04.01"
lifecycleRuntimeKtxVersion = "2.8.6"
navigationCompose = "2.8.1"
retrofit = "2.9.0"
+roomCompiler = "2.6.1"
+roomKtx = "2.6.1"
+roomRuntime = "2.6.1"
swiperefreshlayout = "1.1.0"
googleGmsGoogleServices = "4.4.2"
firebaseMessaging = "24.0.2"
kotlinSerialization = "1.6.0"
+junitKtx = "1.2.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+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" }
+androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomKtx" }
+androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" }
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" }
@@ -60,6 +69,7 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t
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" }
[plugins]
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 823e54f8..cfe6aaaf 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
-#Thu Sep 19 22:47:17 KST 2024
+#Thu Jan 16 23:13:41 KST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists