diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..012c45951 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Read|Edit|Write", + "hooks": [ + { + "type": "command", + "command": "TOOL_INPUT=$(cat); if echo \"$TOOL_INPUT\" | grep -q 'google-services.json'; then echo 'BLOCK: google-services.json은 민감한 파일이므로 접근할 수 없습니다.' >&2; exit 2; fi" + } + ] + } + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 357a274eb..48c388b00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**ChukChukHaksa** (formerly Suwiki) is a Kotlin Multiplatform Compose project for timetable and course evaluation services for Suwon University students. The project has been simplified from a 40+ module architecture to a single `composeApp` module while maintaining Clean Architecture with MVI pattern. +**ChukChukHaksa** (formerly Suwiki) is a Kotlin Multiplatform Compose project for timetable and course evaluation services for Suwon University students. The project uses a single `composeApp` module with Clean Architecture and MVI pattern. Supports Android and iOS with platform-specific widgets. ## Build System and Commands @@ -43,15 +43,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### MVI + Clean Architecture Pattern - **Presentation Layer**: MVI with custom `MviStore` implementation using StateFlow/Flow -- **Domain Layer**: Use cases following single responsibility principle -- **Data Layer**: Repository pattern with Room database and Firebase integration +- **Domain Layer**: Use cases with `suspend operator fun invoke()`, returns `Result` +- **Data Layer**: Repository pattern with local (Room/Datastore) and remote (Firebase) data sources ### Key Architectural Components - **State Management**: Unidirectional Data Flow (UDF) with StateFlow - **Dependency Injection**: Koin 4.1.0-Beta10 with feature-based modules -- **Navigation**: Jetpack Navigation Compose with type-safe routes -- **Database**: Room 2.7.1 with SQLite for local storage -- **Remote Data**: Firebase via GitLive KMP wrappers +- **Navigation**: Jetpack Navigation Compose 2.9.0-beta02 with type-safe routes (kotlinx.serialization for argument passing) +- **Database**: Room 2.7.1 with SQLite for local storage (schema in `composeApp/schemas`) +- **Remote Data**: Firebase via GitLive KMP wrappers 2.1.0 +- **Widgets**: Android (Glance 1.1.1) / iOS (Native WidgetKit) timetable widgets ## Design System @@ -59,11 +60,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co The project uses two parallel design systems: 1. **SuwikiTheme**: Legacy design system with NotoSans fonts - - 17 text styles (header1-7, body1-7, caption1-7) + - 21 text styles (header1-7, body1-7, caption1-7) -2. **CCHaksaTheme**: New design system with Paperlogy + SUIT fonts - - Typography: 10 text styles (titleExlg2, titleExlg, titleLg, bodyExlg, bodyLgStrong, bodyLg, bodyMdStrong, bodyMd, bodySm, bodyXs) - - Colors: Organized palette with Gray, Purple, Red, Yellow, Green variants (100-600 naming) +2. **CchTheme**: New design system with Paperlogy + SUIT fonts + - Typography: 13 text styles (titleExlg2, titleExlg, titleLg, bodyExlg, bodyLgStrong, bodyLg, bodyMdStrong, bodyMd, bodySmStrong, bodySm, bodyXsStrong, bodyXs, bodyXxs) + - Colors: Organized palette with Gray (100-600), Purple (100-600), Red (100-400), Yellow (100-200), Green (100-200) + +### Design System Components +appbar, badge, bottomsheet, button, chip, container, dialog, loading, searchbar, tabbar, textfield, toast ### Design System Location - **Components**: `composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/designsystem/component/` @@ -73,27 +77,44 @@ The project uses two parallel design systems: ## Project Structure ### Multiplatform Targets -- **Android**: Primary target (minSdk 28, targetSdk 35) -- **iOS**: Framework generation for iOS app (iosX64, iosArm64, iosSimulatorArm64) +- **Android**: Primary target (minSdk 28, targetSdk 35, compileSdk 35) +- **iOS**: Framework generation via `iosApp.xcworkspace` (iosX64, iosArm64, iosSimulatorArm64) ### Key Dependencies -- **Compose Multiplatform**: 1.8.1 -- **Kotlin**: 2.1.20 +- **Kotlin**: 2.2.21 +- **KSP**: 2.2.21-2.0.4 +- **Compose Multiplatform**: 1.10.0 - **Koin DI**: 4.1.0-Beta10 - **Room Database**: 2.7.1 -- **Firebase**: Via GitLive KMP wrappers +- **Firebase**: GitLive KMP 2.1.0 (Android BOM 33.2.0) +- **kotlinx-coroutines**: 1.10.1 +- **kotlinx-serialization**: 1.8.1 +- **kotlinx-datetime**: 0.7.1 +- **kotlinx-immutable**: 0.3.8 +- **Datastore Preferences**: 1.1.7 +- **Napier (logging)**: 2.7.1 +- **Glance (Android Widgets)**: 1.1.1 ### Package Organization ``` com.chukchukhaksa.mobile/ ├── common/ │ ├── designsystem/ # UI components and themes -│ ├── model/ # Shared data models -│ └── util/ # Utility functions -├── feature/ # Feature modules (MVI screens) -├── data/ # Repository implementations -├── domain/ # Use cases and domain models -└── di/ # Dependency injection modules +│ ├── extension/ # Kotlin extensions +│ ├── kmp/ # KMP platform-specific code +│ ├── model/ # Shared data models +│ ├── provider/ # Platform providers +│ └── ui/ # Common UI utilities (MviStore) +├── data/ # Repository implementations +├── di/ # Dependency injection modules +├── domain/ # Use cases and repository interfaces +├── local/ # Local data sources +│ ├── database/ # Room database +│ ├── datasource/ # Local data source implementations +│ └── datastore/ # Datastore preferences +├── presentation/ # Feature screens (MVI pattern) +├── remote/ # Remote data sources +└── widget/ # Widget support (expect/actual) ``` ## Development Workflow @@ -101,22 +122,30 @@ com.chukchukhaksa.mobile/ ### Current Project State - **Main Branch**: `develop` - **Application ID**: `com.kunize.uswtimetable` -- **Version**: 2.3.7 (versionCode 41) +- **Version**: 3.0.1 (versionCode 51) +- **Java Compatibility**: Java 17 ### MVI Implementation Pattern -Each screen follows this contract: +Each screen follows this pattern: ```kotlin -interface [Feature]Contract { - data class State(...) - sealed interface SideEffect +// State + SideEffect definitions +data class [Feature]State(...) +sealed interface [Feature]SideEffect + +// ViewModel with MviStore delegate +class [Feature]ViewModel( + private val someUseCase: SomeUseCase +) : ViewModel(), MviStore by mviStore(initialState) { + fun onAction(action: ...) { ... } } - -class [Feature]Store : MviStore ``` +Use cases use `runCatchingIgnoreCancelled` for error handling. + ### Navigation Structure - Feature-based navigation graphs -- Type-safe route arguments +- Type-safe route arguments via kotlinx.serialization (JSON encoding to URI) +- Route object pattern with constant routes and parameterized functions - Nested navigation for complex flows (e.g., timetable → editor → cell editor → lecture search) ### Resource Management @@ -132,9 +161,10 @@ class [Feature]Store : MviStore ## Development Notes ### Performance Optimizations -- Gradle configuration cache enabled -- 4GB heap allocation for builds +- Gradle configuration cache and build cache enabled +- Kotlin daemon: 3GB heap / Gradle JVM: 4GB heap - Non-transitive R classes enabled +- Core library desugaring enabled for Android ### Code Style - Kotlin official code style @@ -144,8 +174,7 @@ class [Feature]Store : MviStore ### Design System Usage When creating new UI components: -1. Use `CCHaksaTheme` for new features +1. Use `CchTheme` for new features 2. Preview components in `preview/designsystem/` package 3. Follow existing color naming conventions (100-600 scale) 4. Prefer typography styles over hardcoded text styles -``` \ No newline at end of file diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 31c773cee..32c5d5dec 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -80,8 +80,6 @@ kotlin { implementation(libs.kmp.firebase.crashlytics) implementation(libs.kmp.firebase.analytics) implementation(libs.napier) - - implementation(libs.accompanist.systemuicontroller) } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/common/kmp/Platform.android.kt b/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/common/kmp/Platform.android.kt index 1d515da48..08363d657 100644 --- a/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/common/kmp/Platform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/common/kmp/Platform.android.kt @@ -1,17 +1,3 @@ package com.chukchukhaksa.mobile.common.kmp -import androidx.compose.ui.graphics.Color -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import com.google.accompanist.systemuicontroller.rememberSystemUiController - actual fun getPlatform(): Platform = Platform.Android - -@Composable -actual fun SetStatusBarColor(){ - val sysUiController = rememberSystemUiController() - - SideEffect { - sysUiController.setSystemBarsColor(color = Color(0xFFFFFFFF)) - } -} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/App.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/App.kt index 9a386567f..039c64816 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/App.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/App.kt @@ -1,5 +1,9 @@ package com.chukchukhaksa.mobile +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.navigationBars @@ -8,6 +12,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp +import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.NavHost import chukchukhaksa.composeapp.generated.resources.Res import chukchukhaksa.composeapp.generated.resources.dialog_network_body @@ -20,7 +25,6 @@ import com.chukchukhaksa.mobile.common.designsystem.component.toast.CchToast import com.chukchukhaksa.mobile.common.designsystem.theme.CchTheme import com.chukchukhaksa.mobile.common.designsystem.theme.White import com.chukchukhaksa.mobile.common.kmp.Platform.* -import com.chukchukhaksa.mobile.common.kmp.SetStatusBarColor import com.chukchukhaksa.mobile.common.kmp.getPlatform import com.chukchukhaksa.mobile.common.ui.collectWithLifecycle import com.chukchukhaksa.mobile.presentation.timetable.navigation.timetableNavGraph @@ -38,7 +42,6 @@ fun App( viewModel: MainViewModel = koinViewModel(), navigator: MainNavigator = rememberMainNavigator(), ) { - SetStatusBarColor() CchTheme { KoinContext { val uiState = viewModel.mviStore.uiState.collectAsState().value @@ -70,11 +73,7 @@ fun App( contentWindowInsets = WindowInsets(0.dp), modifier = modifier, content = { innerPadding -> - NavHost( - navController = navigator.navController, - startDestination = navigator.startDestination, - ) { - + val navGraphBuilder: NavGraphBuilder.() -> Unit = { timetableNavGraph( padding = innerPadding, popBackStack = navigator::popBackStackIfNotHome, @@ -90,6 +89,24 @@ fun App( ) } + when (getPlatform()) { + Android -> NavHost( + navController = navigator.navController, + startDestination = navigator.startDestination, + enterTransition = { slideInHorizontally(tween(350, easing = FastOutSlowInEasing)) { it } }, + exitTransition = { slideOutHorizontally(tween(350, easing = FastOutSlowInEasing)) { -it / 3 } }, + popEnterTransition = { slideInHorizontally(tween(350, easing = FastOutSlowInEasing)) { -it / 3 } }, + popExitTransition = { slideOutHorizontally(tween(350, easing = FastOutSlowInEasing)) { it } }, + builder = navGraphBuilder, + ) + + IOS -> NavHost( + navController = navigator.navController, + startDestination = navigator.startDestination, + builder = navGraphBuilder, + ) + } + if (uiState.showNetworkErrorDialog) { CchDialog( headerText = stringResource(Res.string.dialog_network_header), diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/designsystem/component/SuwikiBackground.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/designsystem/component/SuwikiBackground.kt index 323058b14..c4442e7af 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/designsystem/component/SuwikiBackground.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/designsystem/component/SuwikiBackground.kt @@ -2,6 +2,7 @@ package com.chukchukhaksa.mobile.common.designsystem.component import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.MutableWindowInsets import androidx.compose.foundation.layout.WindowInsets @@ -42,7 +43,6 @@ fun SuwikiBackground( .onConsumedWindowInsetsChanged { consumedWindowInsets -> safeInsets.insets = contentWindowInsets.exclude(consumedWindowInsets) } - .padding(safeInsets.insets.asPaddingValues()) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null @@ -50,6 +50,12 @@ fun SuwikiBackground( focusManager.clearFocus() }, ) { - content() + Box( + modifier = Modifier + .fillMaxSize() + .padding(safeInsets.insets.asPaddingValues()) + ) { + content() + } } } diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/kmp/Platform.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/kmp/Platform.kt index b9cff89c9..f13a117f8 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/kmp/Platform.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/kmp/Platform.kt @@ -1,13 +1,8 @@ package com.chukchukhaksa.mobile.common.kmp -import androidx.compose.runtime.Composable - enum class Platform { Android, IOS } expect fun getPlatform(): Platform - -@Composable -expect fun SetStatusBarColor() diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/ui/MultipleEventsCutter.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/ui/MultipleEventsCutter.kt index 68aade5e6..9ad26c37a 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/ui/MultipleEventsCutter.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/ui/MultipleEventsCutter.kt @@ -1,6 +1,6 @@ package com.chukchukhaksa.mobile.common.ui -import kotlinx.datetime.Clock +import kotlin.time.ExperimentalTime /** * 클릭이 지연될 시간을 정의하는 변수 @@ -17,8 +17,9 @@ internal fun MultipleEventsCutter.Companion.get(): MultipleEventsCutter = MultipleEventsCutterImpl() private class MultipleEventsCutterImpl : MultipleEventsCutter { + @OptIn(ExperimentalTime::class) private val now: Long - get() = Clock.System.now().toEpochMilliseconds() + get() = kotlin.time.Clock.System.now().toEpochMilliseconds() private var lastEventTimeMs: Long = 0 diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/timetable/semesterselect/SemesterSelectContract.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/timetable/semesterselect/SemesterSelectContract.kt index 07e40ef40..6a5120ea7 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/timetable/semesterselect/SemesterSelectContract.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/timetable/semesterselect/SemesterSelectContract.kt @@ -3,9 +3,10 @@ package com.chukchukhaksa.mobile.presentation.timetable.semesterselect import com.chukchukhaksa.mobile.presentation.timetable.navigation.argument.TimetableEditorArgument import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.toPersistentList -import kotlinx.datetime.Clock import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.ExperimentalTime data class SemesterSelectState( val selectSemesterIndex: Int? = null, @@ -17,6 +18,7 @@ sealed interface SemesterSelectSideEffect { data class NavigateTimetableNameInput(val semester: TimetableEditorArgument): SemesterSelectSideEffect } +@OptIn(ExperimentalTime::class) val semesterList: PersistentList = run { val currentYear = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).year val semesterList = mutableListOf() diff --git a/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/Platform.ios.kt b/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/Platform.ios.kt index a7358e627..6f03cbb0c 100644 --- a/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/Platform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/Platform.ios.kt @@ -1,50 +1,3 @@ package com.chukchukhaksa.mobile.common.kmp -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.Color -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.zeroValue -import platform.CoreGraphics.CGRect -import platform.UIKit.UIApplication -import platform.UIKit.UIColor -import platform.UIKit.UIView -import platform.UIKit.UIWindow -import platform.UIKit.statusBarManager -import androidx.compose.runtime.SideEffect - actual fun getPlatform(): Platform = Platform.IOS - -@OptIn(ExperimentalForeignApi::class) -@Composable -private fun statusBarView() = remember { - val keyWindow: UIWindow? = - UIApplication.sharedApplication.windows.firstOrNull { (it as? UIWindow)?.isKeyWindow() == true } as? UIWindow - val tag = 3848245L - - keyWindow?.viewWithTag(tag) ?: run { - val height = - keyWindow?.windowScene?.statusBarManager?.statusBarFrame ?: zeroValue() - val statusBarView = UIView(frame = height) - statusBarView.tag = tag - statusBarView.layer.zPosition = 999999.0 - keyWindow?.addSubview(statusBarView) - statusBarView - } -} - - -@Composable -actual fun SetStatusBarColor() { - val statusBar = statusBarView() - SideEffect { - statusBar.backgroundColor = Color(0xFFFFFFFF).toUIColor() - } -} - -private fun Color.toUIColor(): UIColor = UIColor( - red = this.red.toDouble(), - green = this.green.toDouble(), - blue = this.blue.toDouble(), - alpha = this.alpha.toDouble() -) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab0c75937..d6e3677f7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] -agp = "8.7.3" -android-compileSdk = "35" +agp = "8.10.1" +android-compileSdk = "36" android-minSdk = "28" -android-targetSdk = "35" +android-targetSdk = "36" androidx-activity = "1.10.1" androidx-appcompat = "1.7.0" androidx-constraintlayout = "2.2.1" @@ -10,7 +10,7 @@ androidx-core = "1.16.0" androidx-espresso = "3.6.1" androidx-lifecycle = "2.8.4" androidx-testExt = "1.2.1" -composeMultiplatform = "1.8.1" +composeMultiplatform = "1.10.0" datastorePreferences = "1.1.7" firebaseDatabaseKtx = "21.0.0" junit = "4.13.2" @@ -18,8 +18,8 @@ kotlin = "2.2.21" kotlinx-coroutines = "1.10.1" kotlinx-immutable = "0.3.8" kotlinx-serialization = "1.8.1" -kotlinx-datetime = "0.6.1" -jetbrains-androidx-navigation = "2.9.0-beta02" +kotlinx-datetime = "0.7.1" +jetbrains-androidx-navigation = "2.9.1" koin = "4.1.0-Beta10" androidx-room = "2.7.1" ksp = "2.2.21-2.0.4" @@ -30,9 +30,9 @@ firebaseCrashlytics = "2.9.9" google-service = "4.4.2" desugar-jdk-libs = "2.1.5" napier = "2.7.1" -accompanistSystemuicontroller = "0.30.1" + glance = "1.1.1" -composeRuntime = "1.6.10" +composeRuntime = "1.10.0" [libraries] @@ -81,7 +81,6 @@ desugar-jdk-libs = { group = "com.android.tools", name = "desugar_jdk_libs", ver napier = { group = "io.github.aakira", name = "napier", version.ref = "napier" } -accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanistSystemuicontroller" } glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" } glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 09523c0e5..e2847c820 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME