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