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 @@ - +![Banner](https://github.com/user-attachments/assets/67608836-b085-4d6b-9f6f-faa562e20912) +[](https://play.google.com/store/apps/details?id=com.doyoonkim.knutice) +
+ # ๐Ÿ”” KNUTICE - ๊ตญ๋ฆฝํ•œ๊ตญ๊ตํ†ต๋Œ€ํ•™๊ต ๊ณต์ง€์‚ฌํ•ญ ์•Œ๋ฆฌ๋ฏธ @@ -29,14 +32,13 @@
# ๐Ÿ“ฑ Preview -
- -์Šคํฌ๋ฆฐ์ƒท 2024-08-15 23 51 59 - -์Šคํฌ๋ฆฐ์ƒท 2024-08-15 23 52 56 - -์Šคํฌ๋ฆฐ์ƒท 2024-08-15 23 53 12 - +
+ ์Šคํฌ๋ฆฐ์ƒท 2024-08-15 23 51 59 + ์Šคํฌ๋ฆฐ์ƒท 2024-08-15 23 51 59 + ์Šคํฌ๋ฆฐ์ƒท 2024-08-15 23 51 59 + ์Šคํฌ๋ฆฐ์ƒท 2024-08-15 23 51 59 + ์Šคํฌ๋ฆฐ์ƒท 2024-08-15 23 51 59 + ์Šคํฌ๋ฆฐ์ƒท 2024-08-15 23 51 59

@@ -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