diff --git a/.idea/compiler.xml b/.idea/compiler.xml
index 5421743..fb7f4a8 100644
--- a/.idea/compiler.xml
+++ b/.idea/compiler.xml
@@ -3,4 +3,4 @@
-
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index f8a571a..6e65b66 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -31,9 +31,15 @@
+
+
+
+
+
+
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 5705276..d852bbf 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,4 +1,3 @@
-
@@ -7,4 +6,4 @@
-
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index ab8b170..e8e9d4a 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -51,6 +51,9 @@ dependencies {
// core
implementation(projects.core.network)
+ // data
+ implementation(projects.data.forbidden)
+
// presentation
implementation(projects.presentation.main)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index fdd4d5a..7786d59 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/localstorage/build.gradle.kts b/core/localstorage/build.gradle.kts
index e69de29..6b0ef7c 100644
--- a/core/localstorage/build.gradle.kts
+++ b/core/localstorage/build.gradle.kts
@@ -0,0 +1,19 @@
+import com.kindl.buildlogic.setNamespace
+
+plugins {
+ alias(libs.plugins.kindl.android.library)
+ alias(libs.plugins.kindl.hilt)
+ alias(libs.plugins.kindl.serialization)
+}
+
+android {
+ setNamespace("core.localstorage")
+}
+
+dependencies {
+ //core
+ implementation(projects.core.common)
+
+ implementation(libs.javax.inject)
+ implementation(libs.androidx.datastore.preferences)
+}
diff --git a/core/localstorage/src/main/java/com/focpet/core/localstorage/constant/DataStoreConstant.kt b/core/localstorage/src/main/java/com/focpet/core/localstorage/constant/DataStoreConstant.kt
new file mode 100644
index 0000000..365e1b0
--- /dev/null
+++ b/core/localstorage/src/main/java/com/focpet/core/localstorage/constant/DataStoreConstant.kt
@@ -0,0 +1,8 @@
+package com.focpet.core.localstorage.constant
+
+import androidx.datastore.preferences.core.intPreferencesKey
+
+object DataStoreConstant {
+ const val DATA_STORE_NAME = "kindl_datastore"
+ val KEY_NOTIFICATION_DENIED_COUNT = intPreferencesKey("notification_denied_count")
+}
diff --git a/core/localstorage/src/main/java/com/focpet/core/localstorage/di/LocalStorageModule.kt b/core/localstorage/src/main/java/com/focpet/core/localstorage/di/LocalStorageModule.kt
new file mode 100644
index 0000000..579400f
--- /dev/null
+++ b/core/localstorage/src/main/java/com/focpet/core/localstorage/di/LocalStorageModule.kt
@@ -0,0 +1,35 @@
+package com.focpet.core.localstorage.di
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.preferencesDataStore
+import com.focpet.core.localstorage.constant.DataStoreConstant.DATA_STORE_NAME
+import com.focpet.core.localstorage.permissions.PermissionInfoManager
+import com.focpet.core.localstorage.permissions.PermissionInfoManagerImpl
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object LocalStorageModule {
+ private val Context.dataStore: DataStore by preferencesDataStore(name = DATA_STORE_NAME)
+
+ @Provides
+ @Singleton
+ fun provideDataStore(@ApplicationContext context: Context): DataStore {
+ return context.dataStore
+ }
+
+ @Provides
+ @Singleton
+ fun provideOnboardingManager(
+ dataStore: DataStore,
+ ): PermissionInfoManager {
+ return PermissionInfoManagerImpl(dataStore)
+ }
+}
diff --git a/core/localstorage/src/main/java/com/focpet/core/localstorage/permissions/PermissionInfoManager.kt b/core/localstorage/src/main/java/com/focpet/core/localstorage/permissions/PermissionInfoManager.kt
new file mode 100644
index 0000000..d5272c5
--- /dev/null
+++ b/core/localstorage/src/main/java/com/focpet/core/localstorage/permissions/PermissionInfoManager.kt
@@ -0,0 +1,9 @@
+package com.focpet.core.localstorage.permissions
+
+import kotlinx.coroutines.flow.Flow
+
+interface PermissionInfoManager {
+ suspend fun saveNotificationDeniedCount()
+
+ val notificationDeniedCount: Flow
+}
diff --git a/core/localstorage/src/main/java/com/focpet/core/localstorage/permissions/PermissionInfoManagerImpl.kt b/core/localstorage/src/main/java/com/focpet/core/localstorage/permissions/PermissionInfoManagerImpl.kt
new file mode 100644
index 0000000..a89c001
--- /dev/null
+++ b/core/localstorage/src/main/java/com/focpet/core/localstorage/permissions/PermissionInfoManagerImpl.kt
@@ -0,0 +1,28 @@
+package com.focpet.core.localstorage.permissions
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.Preferences
+import com.focpet.core.localstorage.constant.DataStoreConstant.KEY_NOTIFICATION_DENIED_COUNT
+import com.kindl.core.common.util.suspendRunCatching
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+
+class PermissionInfoManagerImpl @Inject constructor(
+ private val dataStore: DataStore,
+) : PermissionInfoManager {
+ override suspend fun saveNotificationDeniedCount() {
+ suspendRunCatching {
+ dataStore.edit { preferences ->
+ val currentCount = preferences[KEY_NOTIFICATION_DENIED_COUNT] ?: 0
+ preferences[KEY_NOTIFICATION_DENIED_COUNT] = currentCount + 1
+ }
+ }
+ }
+
+ override val notificationDeniedCount: Flow = dataStore.data
+ .map { preferences ->
+ preferences[KEY_NOTIFICATION_DENIED_COUNT] ?: 0
+ }
+}
diff --git a/core/permission/build.gradle.kts b/core/permission/build.gradle.kts
new file mode 100644
index 0000000..217d741
--- /dev/null
+++ b/core/permission/build.gradle.kts
@@ -0,0 +1,10 @@
+import com.kindl.buildlogic.setNamespace
+
+plugins {
+ alias(libs.plugins.kindl.android.library)
+ alias(libs.plugins.kindl.hilt)
+}
+
+android {
+ setNamespace("core.permission")
+}
\ No newline at end of file
diff --git a/core/permission/src/main/AndroidManifest.xml b/core/permission/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..568741e
--- /dev/null
+++ b/core/permission/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/core/permission/src/main/kotlin/com/kindl/core/permission/PermissionChecker.kt b/core/permission/src/main/kotlin/com/kindl/core/permission/PermissionChecker.kt
new file mode 100644
index 0000000..21ff798
--- /dev/null
+++ b/core/permission/src/main/kotlin/com/kindl/core/permission/PermissionChecker.kt
@@ -0,0 +1,78 @@
+package com.kindl.core.permission
+
+import android.Manifest
+import android.app.AppOpsManager
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.PowerManager
+import android.os.Process
+import android.provider.Settings
+
+internal fun interface PermissionChecker {
+ fun isGranted(context: Context): Boolean
+
+ object UsageStats : PermissionChecker {
+ override fun isGranted(context: Context): Boolean {
+ val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
+ val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ appOps.unsafeCheckOpNoThrow(
+ AppOpsManager.OPSTR_GET_USAGE_STATS,
+ Process.myUid(),
+ context.packageName,
+ )
+ } else {
+ appOps.checkOpNoThrow(
+ AppOpsManager.OPSTR_GET_USAGE_STATS,
+ Process.myUid(),
+ context.packageName,
+ )
+ }
+ return mode == AppOpsManager.MODE_ALLOWED
+ }
+ }
+
+ object Overlay : PermissionChecker {
+ override fun isGranted(context: Context): Boolean =
+ Settings.canDrawOverlays(context)
+ }
+
+ object BatteryOptimization : PermissionChecker {
+ override fun isGranted(context: Context): Boolean {
+ val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
+ return pm.isIgnoringBatteryOptimizations(context.packageName)
+ }
+ }
+
+ object PostNotifications : PermissionChecker {
+ override fun isGranted(context: Context): Boolean =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
+ PackageManager.PERMISSION_GRANTED
+ } else {
+ true
+ }
+ }
+
+ object Accessibility : PermissionChecker {
+ override fun isGranted(context: Context): Boolean {
+ val enabledServices = Settings.Secure.getString(
+ context.contentResolver,
+ Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
+ ) ?: return false
+ return enabledServices.split(":")
+ .any { it.startsWith(context.packageName) }
+ }
+ }
+
+ companion object {
+ fun allGranted(context: Context): Boolean =
+ listOf(
+ UsageStats,
+ Overlay,
+ BatteryOptimization,
+ PostNotifications,
+ Accessibility,
+ ).all { it.isGranted(context) }
+ }
+}
diff --git a/core/permission/src/main/kotlin/com/kindl/core/permission/PermissionManager.kt b/core/permission/src/main/kotlin/com/kindl/core/permission/PermissionManager.kt
new file mode 100644
index 0000000..8cca078
--- /dev/null
+++ b/core/permission/src/main/kotlin/com/kindl/core/permission/PermissionManager.kt
@@ -0,0 +1,10 @@
+package com.kindl.core.permission
+
+import android.content.Intent
+
+interface PermissionManager {
+ fun check(type: PermissionType): Boolean
+ fun allFocusPermissionsGranted(): Boolean
+ fun missingFocusPermissions(): List
+ fun openSettings(type: PermissionType) : Intent?
+}
diff --git a/core/permission/src/main/kotlin/com/kindl/core/permission/PermissionManagerImpl.kt b/core/permission/src/main/kotlin/com/kindl/core/permission/PermissionManagerImpl.kt
new file mode 100644
index 0000000..6739e73
--- /dev/null
+++ b/core/permission/src/main/kotlin/com/kindl/core/permission/PermissionManagerImpl.kt
@@ -0,0 +1,59 @@
+package com.kindl.core.permission
+
+import android.content.Context
+import android.content.Intent
+import android.provider.Settings
+import androidx.core.net.toUri
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+
+internal class PermissionManagerImpl @Inject constructor(
+ @param:ApplicationContext private val context: Context,
+) : PermissionManager {
+
+ private val checkers: Map = mapOf(
+ PermissionType.USAGE_STATS to PermissionChecker.UsageStats,
+ PermissionType.OVERLAY to PermissionChecker.Overlay,
+ PermissionType.BATTERY_OPTIMIZATION to PermissionChecker.BatteryOptimization,
+ PermissionType.POST_NOTIFICATIONS to PermissionChecker.PostNotifications,
+ )
+
+ private val focusRequiredPermissions =
+ PermissionType.entries.filter { it.isFocusRequired }
+
+ override fun check(type: PermissionType): Boolean =
+ checkers[type]?.isGranted(context) ?: false
+
+ override fun allFocusPermissionsGranted(): Boolean =
+ focusRequiredPermissions.all { check(it) }
+
+ override fun missingFocusPermissions(): List =
+ focusRequiredPermissions.filter { !check(it) }
+
+ // intent만 반환하고 실제 이동은 ui 레이어에서
+ override fun openSettings(type: PermissionType) : Intent {
+ val packageUri = "package:${context.packageName}".toUri()
+ val intent = when (type) {
+ PermissionType.USAGE_STATS ->
+ Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)
+
+ PermissionType.OVERLAY ->
+ Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, packageUri)
+
+ PermissionType.BATTERY_OPTIMIZATION ->
+ Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, packageUri)
+
+ PermissionType.POST_NOTIFICATIONS ->
+ Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
+ putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
+ }
+
+ PermissionType.ACCESSIBILITY ->
+ Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
+ }.apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+
+ return intent
+ }
+}
diff --git a/core/permission/src/main/kotlin/com/kindl/core/permission/PermissionType.kt b/core/permission/src/main/kotlin/com/kindl/core/permission/PermissionType.kt
new file mode 100644
index 0000000..ac69d3d
--- /dev/null
+++ b/core/permission/src/main/kotlin/com/kindl/core/permission/PermissionType.kt
@@ -0,0 +1,9 @@
+package com.kindl.core.permission
+
+enum class PermissionType(val isFocusRequired: Boolean) {
+ USAGE_STATS(isFocusRequired = true),
+ OVERLAY(isFocusRequired = true),
+ BATTERY_OPTIMIZATION(isFocusRequired = true),
+ POST_NOTIFICATIONS(isFocusRequired = false),
+ ACCESSIBILITY(isFocusRequired = true),
+}
diff --git a/core/permission/src/main/kotlin/com/kindl/core/permission/di/PermissionModule.kt b/core/permission/src/main/kotlin/com/kindl/core/permission/di/PermissionModule.kt
new file mode 100644
index 0000000..991d3e2
--- /dev/null
+++ b/core/permission/src/main/kotlin/com/kindl/core/permission/di/PermissionModule.kt
@@ -0,0 +1,18 @@
+package com.kindl.core.permission.di
+
+import com.kindl.core.permission.PermissionManager
+import com.kindl.core.permission.PermissionManagerImpl
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal abstract class PermissionModule {
+
+ @Binds
+ @Singleton
+ abstract fun bindPermissionManager(impl: PermissionManagerImpl): PermissionManager
+}
diff --git a/core/permission/src/main/kotlin/com/kindl/core/permission/usecase/AreAllFocusPermissionsGrantedUseCase.kt b/core/permission/src/main/kotlin/com/kindl/core/permission/usecase/AreAllFocusPermissionsGrantedUseCase.kt
new file mode 100644
index 0000000..26b6600
--- /dev/null
+++ b/core/permission/src/main/kotlin/com/kindl/core/permission/usecase/AreAllFocusPermissionsGrantedUseCase.kt
@@ -0,0 +1,11 @@
+package com.kindl.core.permission.usecase
+
+import com.kindl.core.permission.PermissionManager
+import javax.inject.Inject
+
+class AreAllFocusPermissionsGrantedUseCase @Inject constructor(
+ private val permissionManager: PermissionManager,
+) {
+ operator fun invoke(): Boolean =
+ permissionManager.allFocusPermissionsGranted()
+}
diff --git a/core/permission/src/main/kotlin/com/kindl/core/permission/usecase/CheckFocusPermissionsUseCase.kt b/core/permission/src/main/kotlin/com/kindl/core/permission/usecase/CheckFocusPermissionsUseCase.kt
new file mode 100644
index 0000000..d998557
--- /dev/null
+++ b/core/permission/src/main/kotlin/com/kindl/core/permission/usecase/CheckFocusPermissionsUseCase.kt
@@ -0,0 +1,12 @@
+package com.kindl.core.permission.usecase
+
+import com.kindl.core.permission.PermissionManager
+import com.kindl.core.permission.PermissionType
+import javax.inject.Inject
+
+class CheckFocusPermissionsUseCase @Inject constructor(
+ private val permissionManager: PermissionManager,
+) {
+ operator fun invoke(): List =
+ permissionManager.missingFocusPermissions()
+}
diff --git a/presentation/.gitignore b/core/service/.gitignore
similarity index 100%
rename from presentation/.gitignore
rename to core/service/.gitignore
diff --git a/core/service/build.gradle.kts b/core/service/build.gradle.kts
new file mode 100644
index 0000000..1f7eb63
--- /dev/null
+++ b/core/service/build.gradle.kts
@@ -0,0 +1,10 @@
+import com.kindl.buildlogic.setNamespace
+
+plugins {
+ alias(libs.plugins.kindl.android.library)
+ alias(libs.plugins.kindl.android.compose)
+}
+
+android {
+ setNamespace("core.service")
+}
diff --git a/core/service/src/main/AndroidManifest.xml b/core/service/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8bdb7e1
--- /dev/null
+++ b/core/service/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/core/work/.gitignore b/core/work/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/core/work/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/work/build.gradle.kts b/core/work/build.gradle.kts
new file mode 100644
index 0000000..f2909d7
--- /dev/null
+++ b/core/work/build.gradle.kts
@@ -0,0 +1,10 @@
+import com.kindl.buildlogic.setNamespace
+
+plugins {
+ alias(libs.plugins.kindl.android.library)
+ alias(libs.plugins.kindl.hilt)
+}
+
+android {
+ setNamespace("core.work")
+}
diff --git a/core/work/src/main/AndroidManifest.xml b/core/work/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8bdb7e1
--- /dev/null
+++ b/core/work/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/data/forbidden/.gitignore b/data/forbidden/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/data/forbidden/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/data/forbidden/build.gradle.kts b/data/forbidden/build.gradle.kts
new file mode 100644
index 0000000..eedf00b
--- /dev/null
+++ b/data/forbidden/build.gradle.kts
@@ -0,0 +1,13 @@
+import com.kindl.buildlogic.setNamespace
+
+plugins {
+ alias(libs.plugins.kindl.android.data)
+}
+
+android {
+ setNamespace("data.forbidden")
+}
+
+dependencies {
+ implementation(projects.domain.forbidden)
+}
diff --git a/data/.gitkeep b/data/forbidden/consumer-rules.pro
similarity index 100%
rename from data/.gitkeep
rename to data/forbidden/consumer-rules.pro
diff --git a/data/forbidden/proguard-rules.pro b/data/forbidden/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/data/forbidden/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/data/forbidden/src/main/AndroidManifest.xml b/data/forbidden/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8bdb7e1
--- /dev/null
+++ b/data/forbidden/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/data/forbidden/src/main/java/com/kindl/data/forbidden/di/ForbiddenRepositoryModule.kt b/data/forbidden/src/main/java/com/kindl/data/forbidden/di/ForbiddenRepositoryModule.kt
new file mode 100644
index 0000000..cca6105
--- /dev/null
+++ b/data/forbidden/src/main/java/com/kindl/data/forbidden/di/ForbiddenRepositoryModule.kt
@@ -0,0 +1,20 @@
+package com.kindl.data.forbidden.di
+
+import com.kindl.data.forbidden.repositoryimpl.ForbiddenAppRepositoryImpl
+import com.kindl.domain.forbidden.repository.ForbiddenAppRepository
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal interface ForbiddenRepositoryModule {
+
+ @Binds
+ @Singleton
+ fun bindForbiddenRepository(
+ impl: ForbiddenAppRepositoryImpl
+ ) : ForbiddenAppRepository
+}
diff --git a/data/forbidden/src/main/java/com/kindl/data/forbidden/repositoryimpl/ForbiddenAppRepositoryImpl.kt b/data/forbidden/src/main/java/com/kindl/data/forbidden/repositoryimpl/ForbiddenAppRepositoryImpl.kt
new file mode 100644
index 0000000..a329a9d
--- /dev/null
+++ b/data/forbidden/src/main/java/com/kindl/data/forbidden/repositoryimpl/ForbiddenAppRepositoryImpl.kt
@@ -0,0 +1,31 @@
+package com.kindl.data.forbidden.repositoryimpl
+
+import android.content.Context
+import android.content.Intent
+import com.kindl.domain.forbidden.model.InstalledAppModel
+import com.kindl.domain.forbidden.repository.ForbiddenAppRepository
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+
+internal class ForbiddenAppRepositoryImpl @Inject constructor(
+ @param:ApplicationContext private val context: Context,
+) : ForbiddenAppRepository {
+ override fun getInstalledUserApps(): List {
+ val pm = context.packageManager
+
+ val intent = Intent(Intent.ACTION_MAIN).apply {
+ addCategory(Intent.CATEGORY_LAUNCHER)
+ }
+
+ return pm.queryIntentActivities(intent, 0)
+ .filter { it.activityInfo.packageName != context.packageName }
+ .map { resolveInfo ->
+ InstalledAppModel(
+ packageName = resolveInfo.activityInfo.packageName,
+ appName = resolveInfo.loadLabel(pm).toString(),
+ //icon = resolveInfo.loadIcon(pm),
+ )
+ }
+ .sortedBy { it.appName }
+ }
+}
diff --git a/domain/forbidden/.gitignore b/domain/forbidden/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/domain/forbidden/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/domain/forbidden/build.gradle.kts b/domain/forbidden/build.gradle.kts
new file mode 100644
index 0000000..7f24de9
--- /dev/null
+++ b/domain/forbidden/build.gradle.kts
@@ -0,0 +1,8 @@
+plugins {
+ alias(libs.plugins.kindl.kotlin.jvm)
+}
+
+dependencies {
+ implementation(libs.javax.inject)
+ implementation(libs.kotlinx.immutable)
+}
diff --git a/domain/forbidden/src/main/java/com/kindl/domain/forbidden/model/InstalledAppModel.kt b/domain/forbidden/src/main/java/com/kindl/domain/forbidden/model/InstalledAppModel.kt
new file mode 100644
index 0000000..6a7f675
--- /dev/null
+++ b/domain/forbidden/src/main/java/com/kindl/domain/forbidden/model/InstalledAppModel.kt
@@ -0,0 +1,6 @@
+package com.kindl.domain.forbidden.model
+
+data class InstalledAppModel(
+ val packageName: String,
+ val appName: String,
+)
diff --git a/domain/forbidden/src/main/java/com/kindl/domain/forbidden/repository/ForbiddenAppRepository.kt b/domain/forbidden/src/main/java/com/kindl/domain/forbidden/repository/ForbiddenAppRepository.kt
new file mode 100644
index 0000000..c7159c4
--- /dev/null
+++ b/domain/forbidden/src/main/java/com/kindl/domain/forbidden/repository/ForbiddenAppRepository.kt
@@ -0,0 +1,7 @@
+package com.kindl.domain.forbidden.repository
+
+import com.kindl.domain.forbidden.model.InstalledAppModel
+
+interface ForbiddenAppRepository {
+ fun getInstalledUserApps(): List
+}
diff --git a/domain/forbidden/src/main/java/com/kindl/domain/forbidden/usecase/GetInstalledAppsUseCase.kt b/domain/forbidden/src/main/java/com/kindl/domain/forbidden/usecase/GetInstalledAppsUseCase.kt
new file mode 100644
index 0000000..92d69e4
--- /dev/null
+++ b/domain/forbidden/src/main/java/com/kindl/domain/forbidden/usecase/GetInstalledAppsUseCase.kt
@@ -0,0 +1,14 @@
+package com.kindl.domain.forbidden.usecase
+
+import com.kindl.domain.forbidden.model.InstalledAppModel
+import com.kindl.domain.forbidden.repository.ForbiddenAppRepository
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+import javax.inject.Inject
+
+class GetInstalledAppsUseCase @Inject constructor(
+ private val appRepository: ForbiddenAppRepository,
+) {
+ operator fun invoke(): ImmutableList =
+ appRepository.getInstalledUserApps().toImmutableList()
+}
diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts
deleted file mode 100644
index f3bd531..0000000
--- a/presentation/build.gradle.kts
+++ /dev/null
@@ -1,13 +0,0 @@
-plugins {
- id("java-library")
- alias(libs.plugins.jetbrains.kotlin.jvm)
-}
-java {
- sourceCompatibility = JavaVersion.VERSION_11
- targetCompatibility = JavaVersion.VERSION_11
-}
-kotlin {
- compilerOptions {
- jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11
- }
-}
diff --git a/presentation/home/.gitignore b/presentation/home/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/presentation/home/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/presentation/home/build.gradle.kts b/presentation/home/build.gradle.kts
new file mode 100644
index 0000000..a850c91
--- /dev/null
+++ b/presentation/home/build.gradle.kts
@@ -0,0 +1,14 @@
+import com.kindl.buildlogic.setNamespace
+
+plugins {
+ alias(libs.plugins.kindl.android.presentation)
+}
+
+android {
+ setNamespace("presentation.home")
+}
+
+dependencies {
+ // domain
+ implementation(projects.domain.forbidden)
+}
diff --git a/domain/.gitkeep b/presentation/home/consumer-rules.pro
similarity index 100%
rename from domain/.gitkeep
rename to presentation/home/consumer-rules.pro
diff --git a/presentation/home/proguard-rules.pro b/presentation/home/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/presentation/home/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/presentation/home/src/main/AndroidManifest.xml b/presentation/home/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8bdb7e1
--- /dev/null
+++ b/presentation/home/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/presentation/home/src/main/java/com/kindl/presentation/home/HomeContract.kt b/presentation/home/src/main/java/com/kindl/presentation/home/HomeContract.kt
new file mode 100644
index 0000000..7d968ed
--- /dev/null
+++ b/presentation/home/src/main/java/com/kindl/presentation/home/HomeContract.kt
@@ -0,0 +1,11 @@
+package com.kindl.presentation.home
+
+import androidx.compose.runtime.Immutable
+import com.kindl.presentation.home.model.ForbiddenAppUiModel
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Immutable
+data class HomeState(
+ val forbiddenApps: ImmutableList = persistentListOf(),
+)
\ No newline at end of file
diff --git a/presentation/home/src/main/java/com/kindl/presentation/home/HomeScreen.kt b/presentation/home/src/main/java/com/kindl/presentation/home/HomeScreen.kt
new file mode 100644
index 0000000..25037f3
--- /dev/null
+++ b/presentation/home/src/main/java/com/kindl/presentation/home/HomeScreen.kt
@@ -0,0 +1,57 @@
+package com.kindl.presentation.home
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.kindl.presentation.home.component.ForbiddenAppItem
+
+@Composable
+internal fun HomeRoute(
+ paddingValues: PaddingValues,
+ viewModel: HomeViewModel = hiltViewModel()
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+
+ HomeScreen(
+ paddingValues = paddingValues,
+ state = state
+ )
+
+}
+
+@Composable
+private fun HomeScreen(
+ paddingValues: PaddingValues,
+ state: HomeState
+) {
+ Column (
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .background(color = Color.White),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ LazyColumn(
+ modifier = Modifier.weight(1f),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ items(
+ count = state.forbiddenApps.size,
+ key = { index -> state.forbiddenApps[index].appPackageName },
+ contentType = { "forbidden_app" }
+ ) { index ->
+ ForbiddenAppItem(item = state.forbiddenApps[index])
+ }
+ }
+ }
+}
diff --git a/presentation/home/src/main/java/com/kindl/presentation/home/HomeViewModel.kt b/presentation/home/src/main/java/com/kindl/presentation/home/HomeViewModel.kt
new file mode 100644
index 0000000..2c0cc20
--- /dev/null
+++ b/presentation/home/src/main/java/com/kindl/presentation/home/HomeViewModel.kt
@@ -0,0 +1,44 @@
+package com.kindl.presentation.home
+
+import android.content.Context
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.kindl.domain.forbidden.usecase.GetInstalledAppsUseCase
+import com.kindl.presentation.home.model.ForbiddenAppUiModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+internal class HomeViewModel @Inject constructor(
+ @param:ApplicationContext private val context: Context,
+ private val getInstalledAppsUseCase: GetInstalledAppsUseCase,
+) : ViewModel() {
+ private val _state = MutableStateFlow(HomeState())
+ val state = _state.asStateFlow()
+
+ init {
+ loadApps()
+ }
+
+ private fun loadApps() {
+ viewModelScope.launch(Dispatchers.IO) {
+ val pm = context.packageManager
+ val uiModels = getInstalledAppsUseCase().map { app ->
+ ForbiddenAppUiModel(
+ appPackageName = app.packageName,
+ appName = app.appName,
+ icon = runCatching { pm.getApplicationIcon(app.packageName) }.getOrNull(),
+ )
+ }.toImmutableList()
+
+ _state.update { it.copy(forbiddenApps = uiModels) }
+ }
+ }
+}
diff --git a/presentation/home/src/main/java/com/kindl/presentation/home/component/ForbiddenAppItem.kt b/presentation/home/src/main/java/com/kindl/presentation/home/component/ForbiddenAppItem.kt
new file mode 100644
index 0000000..1298f7f
--- /dev/null
+++ b/presentation/home/src/main/java/com/kindl/presentation/home/component/ForbiddenAppItem.kt
@@ -0,0 +1,51 @@
+package com.kindl.presentation.home.component
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.kindl.core.designsystem.component.UrlImage
+import com.kindl.core.designsystem.theme.KindlTheme
+import com.kindl.presentation.home.model.ForbiddenAppUiModel
+
+@Composable
+internal fun ForbiddenAppItem(
+ item: ForbiddenAppUiModel,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ UrlImage(
+ url = item.icon,
+ modifier = Modifier
+ .size(36.dp)
+ .clip(RoundedCornerShape(16.dp))
+ )
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Text(text = item.appName)
+ }
+}
+
+@Preview
+@Composable
+private fun ForbiddenAppItemPreview() {
+ KindlTheme {
+ ForbiddenAppItem(
+ item = ForbiddenAppUiModel(
+ appPackageName = "",
+ appName = "test"
+ )
+ )
+ }
+}
diff --git a/presentation/home/src/main/java/com/kindl/presentation/home/model/ForbiddenAppUiModel.kt b/presentation/home/src/main/java/com/kindl/presentation/home/model/ForbiddenAppUiModel.kt
new file mode 100644
index 0000000..106cfd5
--- /dev/null
+++ b/presentation/home/src/main/java/com/kindl/presentation/home/model/ForbiddenAppUiModel.kt
@@ -0,0 +1,11 @@
+package com.kindl.presentation.home.model
+
+import android.graphics.drawable.Drawable
+import androidx.compose.runtime.Stable
+
+@Stable
+data class ForbiddenAppUiModel(
+ val appPackageName: String = "",
+ val appName: String = "",
+ val icon: Drawable? = null,
+)
diff --git a/presentation/home/src/main/java/com/kindl/presentation/home/navigation/HomeNavigation.kt b/presentation/home/src/main/java/com/kindl/presentation/home/navigation/HomeNavigation.kt
new file mode 100644
index 0000000..3e2a21c
--- /dev/null
+++ b/presentation/home/src/main/java/com/kindl/presentation/home/navigation/HomeNavigation.kt
@@ -0,0 +1,29 @@
+package com.kindl.presentation.home.navigation
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import com.kindl.core.navigation.MainTabRoute
+import com.kindl.presentation.home.HomeRoute
+import kotlinx.serialization.Serializable
+
+fun NavController.navigateToHome(
+ navOptions: NavOptions? = null
+) {
+ navigate(Home, navOptions)
+}
+
+fun NavGraphBuilder.homeNavGraph(
+ paddingValues: PaddingValues
+) {
+ composable {
+ HomeRoute(
+ paddingValues = paddingValues
+ )
+ }
+}
+
+@Serializable
+data object Home: MainTabRoute
diff --git a/presentation/main/build.gradle.kts b/presentation/main/build.gradle.kts
index 332f3fe..5a0340c 100644
--- a/presentation/main/build.gradle.kts
+++ b/presentation/main/build.gradle.kts
@@ -13,4 +13,10 @@ android {
}
dependencies {
+ //core
+ implementation(projects.core.permission)
+ implementation(projects.core.localstorage)
+
+ //presentation
+ implementation(projects.presentation.home)
}
diff --git a/presentation/main/src/main/java/com/kindl/presentation/main/MainScreen.kt b/presentation/main/src/main/java/com/kindl/presentation/main/MainScreen.kt
index 5607de0..cf432f0 100644
--- a/presentation/main/src/main/java/com/kindl/presentation/main/MainScreen.kt
+++ b/presentation/main/src/main/java/com/kindl/presentation/main/MainScreen.kt
@@ -1,6 +1,17 @@
package com.kindl.presentation.main
+import android.Manifest
+import android.annotation.SuppressLint
+import android.os.Build
import android.widget.Toast
+import androidx.activity.compose.LocalActivity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -9,29 +20,62 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
+import androidx.core.app.ActivityCompat
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.compose.NavHost
+import com.kindl.core.common.extension.collectSideEffect
import com.kindl.core.common.model.DialogTrigger
import com.kindl.core.common.model.GlobalUiEventHolder
import com.kindl.core.common.model.SnackbarState
import com.kindl.core.common.trigger.LocalGlobalUiEventTrigger
+import com.kindl.core.permission.PermissionType
+import com.kindl.presentation.home.navigation.homeNavGraph
+import com.kindl.presentation.main.component.MainBottomBar
+import com.kindl.presentation.main.component.permission.PermissionScreen
import com.kindl.presentation.main.state.MainAppState
+import com.kindl.presentation.main.state.MainSideEffect
import com.kindl.presentation.main.state.rememberDialogStateHolder
import com.kindl.presentation.main.state.rememberMainAppState
+import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
+@SuppressLint("BatteryLife")
@Composable
internal fun MainScreen(
appState: MainAppState = rememberMainAppState(),
+ viewModel: MainViewModel = hiltViewModel(),
) {
val context = LocalContext.current
+ val activity = LocalActivity.current
val scope = rememberCoroutineScope()
+ val isBottomBarVisible by appState.isBottomBarVisible.collectAsStateWithLifecycle()
+ val currentTab by appState.currentTab.collectAsStateWithLifecycle()
val dialogState = rememberDialogStateHolder()
+ val state by viewModel.state.collectAsStateWithLifecycle()
val snackBarHostState = remember { SnackbarHostState() }
var currentSnackbarState by remember { mutableStateOf(null) }
+ // POST_NOTIFICATIONS 런타임 권한 요청 런처
+ val notificationPermissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission()
+ ) { isGranted ->
+ if (isGranted) {
+ viewModel.checkPermissions()
+ } else {
+ val isPermanentlyDenied = !ActivityCompat.shouldShowRequestPermissionRationale(
+ activity!!,
+ Manifest.permission.POST_NOTIFICATIONS,
+ )
+ viewModel.onNotificationPermissionDenied(isPermanentlyDenied)
+ }
+ }
+
val onShowToast: (String) -> Unit = remember {
{ message ->
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
@@ -75,9 +119,105 @@ internal fun MainScreen(
)
}
+ viewModel.sideEffect.collectSideEffect {
+ when (it) {
+ is MainSideEffect.OpenSettings -> {
+ context.startActivity(it.intent)
+ }
+
+ else -> {
+
+ }
+ }
+ }
+
+ if (state.isPermissionCheckComplete && !state.hasPermissions) {
+ PermissionScreen(
+ missingPermissions = state.missingPermissions,
+ isPermanentlyDenied = state.isPermanentlyDenied,
+ notificationDeniedCount = state.notificationDeniedCount,
+ onRequestPermission = { type ->
+ when (type) {
+ PermissionType.POST_NOTIFICATIONS -> {
+ val neverRequested = state.notificationDeniedCount == 0
+ val isPermanentlyDenied = !ActivityCompat.shouldShowRequestPermissionRationale(
+ activity!!,
+ Manifest.permission.POST_NOTIFICATIONS,
+ )
+ when {
+ // 처음 요청
+ neverRequested -> {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+ // 영구 거절 → 설정으로
+ isPermanentlyDenied -> viewModel.onOpenSettings(type)
+ // 1회 거절 → 재요청
+ else -> {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+ }
+ }
+ // 나머지는 항상 설정으로
+ else -> viewModel.onOpenSettings(type)
+ }
+ },
+ onPermissionGranted = viewModel::checkPermissions,
+ )
+ return
+ }
+
CompositionLocalProvider(
LocalGlobalUiEventTrigger provides eventHolder,
) {
-
+ Scaffold (
+ bottomBar = {
+ if (isBottomBarVisible == true) {
+ MainBottomBar(
+ tabs = MainTab.entries.toImmutableList(),
+ currentTab = currentTab,
+ onTabSelected = appState::navigate,
+ )
+ }
+ },
+ modifier = Modifier
+ .fillMaxSize()
+ ) { paddingValues ->
+ NavHost(
+ navController = appState.navController,
+ startDestination = appState.startDestination,
+ enterTransition = {
+ slideInHorizontally(
+ initialOffsetX = { fullWidth -> fullWidth },
+ animationSpec = tween(durationMillis = 300)
+ )
+ },
+ exitTransition = {
+ slideOutHorizontally(
+ targetOffsetX = { fullWidth -> -fullWidth },
+ animationSpec = tween(durationMillis = 300)
+ )
+ },
+ popEnterTransition = {
+ slideInHorizontally(
+ initialOffsetX = { fullWidth -> -fullWidth },
+ animationSpec = tween(durationMillis = 300)
+ )
+ },
+ popExitTransition = {
+ slideOutHorizontally(
+ targetOffsetX = { fullWidth -> fullWidth },
+ animationSpec = tween(durationMillis = 300)
+ )
+ },
+ ) {
+ homeNavGraph(
+ paddingValues = paddingValues
+ )
+ }
+ }
}
}
diff --git a/presentation/main/src/main/java/com/kindl/presentation/main/MainTab.kt b/presentation/main/src/main/java/com/kindl/presentation/main/MainTab.kt
index ce1e3a0..3f8f7d2 100644
--- a/presentation/main/src/main/java/com/kindl/presentation/main/MainTab.kt
+++ b/presentation/main/src/main/java/com/kindl/presentation/main/MainTab.kt
@@ -3,13 +3,18 @@ package com.kindl.presentation.main
import androidx.annotation.DrawableRes
import com.kindl.core.navigation.MainTabRoute
import com.kindl.core.navigation.Route
+import com.kindl.presentation.home.navigation.Home
internal enum class MainTab(
@param:DrawableRes val iconRes: Int,
val route: MainTabRoute,
val label: String,
) {
- ;
+ HOME(
+ iconRes = R.drawable.baseline_home_24,
+ route = Home,
+ label = "홈",
+ );
companion object {
fun find(predicate: (MainTabRoute) -> Boolean): MainTab? = entries.find { predicate(it.route) }
diff --git a/presentation/main/src/main/java/com/kindl/presentation/main/MainViewModel.kt b/presentation/main/src/main/java/com/kindl/presentation/main/MainViewModel.kt
new file mode 100644
index 0000000..92fddf9
--- /dev/null
+++ b/presentation/main/src/main/java/com/kindl/presentation/main/MainViewModel.kt
@@ -0,0 +1,80 @@
+package com.kindl.presentation.main
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.focpet.core.localstorage.permissions.PermissionInfoManager
+import com.kindl.core.permission.PermissionManager
+import com.kindl.core.permission.PermissionType
+import com.kindl.core.permission.usecase.CheckFocusPermissionsUseCase
+import com.kindl.presentation.main.state.MainSideEffect
+import com.kindl.presentation.main.state.MainUiState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class MainViewModel @Inject constructor(
+ private val checkFocusPermissions: CheckFocusPermissionsUseCase,
+ private val permissionInfoManager: PermissionInfoManager, // 설정 거부 검증용
+ private val permissionManager: PermissionManager, // 실제 퍼미션 체크용
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(MainUiState())
+ val state: StateFlow = _state.asStateFlow()
+
+ private val _sideEffect = MutableSharedFlow()
+ val sideEffect: SharedFlow = _sideEffect.asSharedFlow()
+
+ init {
+ viewModelScope.launch {
+ val deniedCount = permissionInfoManager.notificationDeniedCount.first()
+ _state.update {
+ it.copy(notificationDeniedCount = deniedCount)
+ }
+
+ checkPermissions()
+ }
+ }
+
+ fun checkPermissions() {
+ val missingPermissions = checkFocusPermissions()
+ _state.update {
+ it.copy(
+ missingPermissions = missingPermissions.toImmutableList(),
+ isPermissionCheckComplete = true,
+ )
+ }
+ }
+
+ fun onNotificationPermissionDenied(isPermanentlyDenied: Boolean) {
+ viewModelScope.launch {
+ permissionInfoManager.saveNotificationDeniedCount()
+
+ val deniedCount = permissionInfoManager.notificationDeniedCount.first()
+ _state.update {
+ it.copy(
+ isPermanentlyDenied = isPermanentlyDenied,
+ notificationDeniedCount = deniedCount,
+ )
+ }
+ }
+ }
+
+ fun onOpenSettings(type: PermissionType) {
+ viewModelScope.launch {
+ val intent = permissionManager.openSettings(type)
+ _sideEffect.emit(MainSideEffect.OpenSettings(intent))
+ }
+ }
+
+
+}
diff --git a/presentation/main/src/main/java/com/kindl/presentation/main/component/MainBottomBar.kt b/presentation/main/src/main/java/com/kindl/presentation/main/component/MainBottomBar.kt
new file mode 100644
index 0000000..331950f
--- /dev/null
+++ b/presentation/main/src/main/java/com/kindl/presentation/main/component/MainBottomBar.kt
@@ -0,0 +1,94 @@
+package com.kindl.presentation.main.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.key
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.kindl.core.common.extension.noRippleClickable
+import com.kindl.core.designsystem.theme.KindlTheme
+import com.kindl.presentation.main.MainTab
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+
+@Composable
+internal fun MainBottomBar(
+ tabs: ImmutableList,
+ currentTab: MainTab?,
+ onTabSelected: (MainTab) -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ ) {
+ HorizontalDivider(
+ thickness = 1.dp,
+ )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(24.dp),
+ modifier = Modifier
+ .padding(vertical = 8.dp)
+ .fillMaxWidth(),
+ ) {
+ tabs.forEach { tab ->
+ key(tab.route) {
+ MainBottomBarItem(
+ tab = tab,
+ selected = (tab == currentTab),
+ onClick = { onTabSelected(tab) },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RowScope.MainBottomBarItem(
+ tab: MainTab,
+ selected: Boolean,
+ onClick: () -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .noRippleClickable(onClick = onClick)
+ .weight(1f),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Icon(
+ imageVector = ImageVector.vectorResource(tab.iconRes),
+ modifier = Modifier.size(24.dp),
+ contentDescription = tab.label,
+ )
+ Text(
+ text = tab.label,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun MainBottombarPreview() {
+ KindlTheme {
+ MainBottomBar(
+ tabs = MainTab.entries.toImmutableList(),
+ currentTab = MainTab.HOME,
+ onTabSelected = {},
+ )
+ }
+}
diff --git a/presentation/main/src/main/java/com/kindl/presentation/main/component/permission/PermissionScreen.kt b/presentation/main/src/main/java/com/kindl/presentation/main/component/permission/PermissionScreen.kt
new file mode 100644
index 0000000..311c7d3
--- /dev/null
+++ b/presentation/main/src/main/java/com/kindl/presentation/main/component/permission/PermissionScreen.kt
@@ -0,0 +1,114 @@
+package com.kindl.presentation.main.component.permission
+
+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.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import com.kindl.core.designsystem.theme.KindlTheme
+import com.kindl.core.permission.PermissionType
+import kotlinx.collections.immutable.ImmutableList
+
+@Composable
+fun PermissionScreen(
+ missingPermissions: ImmutableList,
+ isPermanentlyDenied: Boolean,
+ notificationDeniedCount: Int,
+ onRequestPermission: (PermissionType) -> Unit,
+ onPermissionGranted: () -> Unit,
+) {
+ LifecycleResumeEffect(Unit) {
+ onPermissionGranted()
+
+ onPauseOrDispose {
+
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ missingPermissions.forEach { type ->
+ PermissionItem(
+ type = type,
+ isPermanentlyDenied = isPermanentlyDenied && type == PermissionType.POST_NOTIFICATIONS,
+ notificationDeniedCount = notificationDeniedCount,
+ onRequest = { onRequestPermission(type) },
+ )
+ }
+ }
+}
+
+@Composable
+private fun PermissionItem(
+ type: PermissionType,
+ isPermanentlyDenied: Boolean,
+ notificationDeniedCount: Int,
+ onRequest: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val (title, description) = when (type) {
+ PermissionType.USAGE_STATS -> "앱 사용 정보" to "금지 앱 감지를 위해 필요해요"
+ PermissionType.OVERLAY -> "다른 앱 위에 표시" to "집중 방해 시 경고창 표시에 필요해요"
+ PermissionType.BATTERY_OPTIMIZATION -> "배터리 최적화 제외" to "백그라운드 감지를 위해 필요해요"
+ PermissionType.POST_NOTIFICATIONS -> "알림 권한" to "집중 시간 알림을 위해 필요해요"
+ PermissionType.ACCESSIBILITY -> "접근 권한" to "앱 접근 시 필요해요"
+ }
+
+ val buttonText = when {
+ type == PermissionType.POST_NOTIFICATIONS && isPermanentlyDenied -> "설정에서 허용"
+ else -> "권한 허용"
+ }
+
+ val descriptionText = when {
+ type == PermissionType.POST_NOTIFICATIONS && isPermanentlyDenied ->
+ "설정에서 직접 알림 권한을 허용해주세요"
+ else -> description
+ }
+
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(text = title)
+ Text(text = descriptionText)
+ }
+ Button(onClick = onRequest) {
+ Text(text = buttonText)
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun PermissionItemPreview() {
+ KindlTheme {
+ PermissionItem(
+ type = PermissionType.POST_NOTIFICATIONS,
+ isPermanentlyDenied = false,
+ notificationDeniedCount = 0,
+ onRequest = {},
+ )
+ }
+}
diff --git a/presentation/main/src/main/java/com/kindl/presentation/main/state/MainAppState.kt b/presentation/main/src/main/java/com/kindl/presentation/main/state/MainAppState.kt
index 80de192..e26dcc7 100644
--- a/presentation/main/src/main/java/com/kindl/presentation/main/state/MainAppState.kt
+++ b/presentation/main/src/main/java/com/kindl/presentation/main/state/MainAppState.kt
@@ -8,6 +8,7 @@ import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions
+import com.kindl.presentation.home.navigation.Home
import com.kindl.presentation.main.MainTab
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
@@ -20,9 +21,9 @@ internal class MainAppState(
val navController: NavHostController,
coroutineScope: CoroutineScope,
) {
- /*val startDestination = Splash
+ val startDestination = Home
- val isOffline: StateFlow = networkMonitor.isOnline
+ /*val isOffline: StateFlow = networkMonitor.isOnline
.map(Boolean::not)
.stateIn(
scope = coroutineScope,
@@ -81,9 +82,9 @@ internal class MainAppState(
launchSingleTop
}
- /*when (tab) {
-
- }*/
+ when (tab) {
+ MainTab.HOME -> navController.navigate(Home, navOptions)
+ }
}
private val clearStackNavOptions = navOptions {
diff --git a/presentation/main/src/main/java/com/kindl/presentation/main/state/MainUiState.kt b/presentation/main/src/main/java/com/kindl/presentation/main/state/MainUiState.kt
new file mode 100644
index 0000000..3a0a12d
--- /dev/null
+++ b/presentation/main/src/main/java/com/kindl/presentation/main/state/MainUiState.kt
@@ -0,0 +1,19 @@
+package com.kindl.presentation.main.state
+
+import android.content.Intent
+import com.kindl.core.permission.PermissionType
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+data class MainUiState(
+ val missingPermissions: ImmutableList = persistentListOf(),
+ val isPermissionCheckComplete: Boolean = false,
+ val isPermanentlyDenied: Boolean = false,
+ val notificationDeniedCount: Int = 0,
+) {
+ val hasPermissions: Boolean get() = missingPermissions.isEmpty()
+}
+
+sealed interface MainSideEffect {
+ data class OpenSettings(val intent: Intent?) : MainSideEffect
+}
diff --git a/presentation/main/src/main/res/drawable/baseline_home_24.xml b/presentation/main/src/main/res/drawable/baseline_home_24.xml
new file mode 100644
index 0000000..20cb4d6
--- /dev/null
+++ b/presentation/main/src/main/res/drawable/baseline_home_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/settings.gradle.kts b/settings.gradle.kts
index de68e06..bdf0fbc 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -25,18 +25,29 @@ rootProject.name = "Kindl"
include(":app")
// core
-include(":core:designsystem")
-include(":core:common")
-include(":core:navigation")
-include(":core:network")
-include(":core:localstorage")
+include(
+ ":core:designsystem",
+ ":core:common",
+ ":core:navigation",
+ ":core:network",
+ ":core:localstorage",
+ ":core:permission",
+ ":core:service",
+ ":core:work"
+)
// data
-include(":data")
+include(
+ ":data:forbidden"
+)
// domain
-include(":domain")
+include(
+ ":domain:forbidden"
+)
// presentation
-include(":presentation:main")
-
+include(
+ ":presentation:main",
+ ":presentation:home"
+)